above 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73c14594eb00a337f64a37313c2105a4e2075c372b1e22e2c5a6d681d5b0a8a3
4
- data.tar.gz: '093ba806cb64f9e6283d66c4273cd1355f5d5620f7f71460acc6bdc3f76961ae'
3
+ metadata.gz: 06f99a9ac81e345a4b7298476432e22a3ea0fb3d74b83009e5635037355f6f8a
4
+ data.tar.gz: 7dac2bef090f98497ba0a99e59c6cabe456e2758ec7e979e8eb91a337523f959
5
5
  SHA512:
6
- metadata.gz: 8c4a24b30c8f5fc8d99114486f678021e590924aa0e3565fe7d4d59089fafbb715724e609ef41cae089a8acd3a77f5f637ede07c8053415dd1a26c1498741be3
7
- data.tar.gz: 1e10259b6b5e3c39c6bf9395903b059d91c08f77f84173308f7aeaf65b3284fc066e387abba530c0d2319c5810091fa070d96310215968fddf4d35185643751e
6
+ metadata.gz: d60ef771694f50e5791b8aa3a62f01f46531accabbfbcbc7df9dba0df9a09951738dccd1530c145ff470a2736c7392d51a926b2b523a1dc5669bcd30270fbbfc
7
+ data.tar.gz: af53e6beffc417e0ce830bee4f80dbd5f1e9d517fad26416d9cf8fe231a83ca6faa927b12fe63437fa5430c56ccc4ba38da910c87526183de5353db0159818d2
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
- ## [Unreleased]
1
+ # Above Changelog
2
2
 
3
- ## [0.1.0] - 13 August 2024
3
+ ## 0.3.0 - 27 August 2024
4
+
5
+ - minor refactor, but includes the link to the repo from the Gemspec (sigh)
6
+
7
+ ## 0.2.0 - 24 August 2024
4
8
 
5
9
  - Initial release
10
+
11
+ ## 0.1.0 - 13 August 2024
12
+
13
+ - Placeholder gem & repo
data/README.md CHANGED
@@ -1,20 +1,31 @@
1
1
  # Above
2
2
 
3
- Initial commit only
3
+ An async Gemini Protocol server with support for multi-tenancy. Above supports middleware, with default middleware for blocking, naive in-memory caching, logging, redirects, & a static file server included. Also included are utilities for certificate creation & DNS-based authentication (DANE).
4
4
 
5
- ## Installation
5
+ ## Status
6
6
 
7
- ...
7
+ 3 - Alpha in Python terms.
8
8
 
9
- ## Usage
9
+ (Those Python statuses are 1 - Planning, 2 - Pre-alpha, 3 - Alpha, 4 - Beta, 5 - Production/Stable, 6 - Mature, 7 - Inactive.)
10
10
 
11
- ...
11
+ Currently not handled:
12
+ - user input or client certificates
13
+ - proxy requests to other domains or protocols
14
+ - anything not quite in the standard. Maybe...
12
15
 
13
- ## Status
16
+ ## Why
14
17
 
15
- ... in Python terms.
18
+ I've read that the heyday of the Gemini Protocol's passed. I don't know, even if it has passed it's still an interesting take on fixing some of what ails the web. If only to delve into the depths of TCP & TLS, & to appreciate again the wonders of HTTP.
16
19
 
17
- (Those Python statuses are 1 - Planning, 2 - Pre-alpha, 3 - Alpha, 4 - Beta, 5 - Production/Stable, 6 - Mature, 7 - Inactive.)
20
+ None of the other Gemini servers I looked at offered quite the same mix of features as Above in quite the same way. So here we are.
21
+
22
+ ## Installation
23
+
24
+ `gem install above`
25
+
26
+ ## Usage
27
+
28
+ ...
18
29
 
19
30
  ## Development
20
31
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Above
6
+ # Create and load certificates
7
+ class Certs
8
+ def dane(domain_arr:, dir:)
9
+ file = File.read("#{File.join(dir, domain_arr.first)}.crt")
10
+ cert = OpenSSL::X509::Certificate.new(file)
11
+ checksum = OpenSSL::Digest::SHA512.new(cert.to_der)
12
+ puts "For DANE create:\n\n"
13
+ domain_arr.each do |domain|
14
+ puts "DNS record: _1965._tcp.#{domain}\n"
15
+ end
16
+ puts <<~HELP
17
+
18
+ Each with the text: TLSA 3 1 2 #{checksum}
19
+
20
+ 312 meaning the certificate's self signed, check the public cert, against this SHA-512 hash
21
+ See: https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities
22
+ HELP
23
+ end
24
+
25
+ # Create key & cert for domain in directory, with a one year duration
26
+ def create(domain_arr:, dir:, duration: 60 * 60 * 24 * 365)
27
+ # RSA is new & length, not generate & algo
28
+ key = OpenSSL::PKey::EC.generate("secp384r1")
29
+ name = OpenSSL::X509::Name.parse("/CN=#{domain_arr.first}")
30
+
31
+ time = Time.now
32
+ cert = OpenSSL::X509::Certificate.new
33
+ cert.version = 2
34
+ cert.serial = 0
35
+ cert.not_before = time
36
+ cert.not_after = time + duration
37
+ # RSA is key.public_key not key - why are they different!?
38
+ cert.public_key = key
39
+ cert.subject = name
40
+
41
+ extension_factory = OpenSSL::X509::ExtensionFactory.new(nil, cert)
42
+ cert.add_extension \
43
+ extension_factory.create_extension("basicConstraints", "CA:FALSE", true)
44
+ cert.add_extension \
45
+ extension_factory.create_extension(
46
+ "keyUsage", "nonRepudiation, digitalSignature, keyEncipherment"
47
+ )
48
+ dns_str = ""
49
+ domain_arr.each do |domain|
50
+ dns_str += "DNS:#{domain},"
51
+ end
52
+ dns_str.chomp!(",")
53
+ cert.add_extension \
54
+ extension_factory.create_extension(
55
+ "subjectAltName", dns_str
56
+ )
57
+ cert.add_extension \
58
+ extension_factory.create_extension(
59
+ "extendedKeyUsage", "serverAuth, clientAuth"
60
+ )
61
+ cert.add_extension extension_factory.create_extension("subjectKeyIdentifier", "hash")
62
+
63
+ cert.issuer = name
64
+ cert.sign key, OpenSSL::Digest.new("SHA512")
65
+
66
+ File.write(File.join(dir, "#{domain_arr.first}.crt"), cert.to_pem)
67
+ File.write(File.join(dir, "#{domain_arr.first}.key"), key.to_pem)
68
+ end
69
+ end
70
+ end
data/lib/above/cli.rb CHANGED
@@ -1,22 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "certs"
5
+ require_relative "config"
6
+ require_relative "server"
4
7
 
5
8
  module Above
6
9
  # CLI handled by optparse
7
10
  class CLI
8
11
  def start(*_args)
12
+ @options = {}
9
13
  OptionParser.new do |opts|
10
14
  opts.banner = <<~BANNER
11
15
  Above
12
- Expanded help here
16
+ A Gemini Protocol server
13
17
 
14
18
  Commands:
15
19
  BANNER
20
+ certs(opts)
21
+ dir(opts)
22
+ server(opts)
16
23
  help(opts)
17
- options_other(opts)
24
+
18
25
  version(opts)
19
26
  end.parse!
27
+
28
+ create_cert
29
+ start_server
30
+ rescue OptionParser::InvalidOption
31
+ puts "Invalid option"
32
+ # Don't have opts here to show help
33
+ end
34
+
35
+ def certs(opts)
36
+ opts.on("-c", "--cert DOMAINS", "Make a certificate for DOMAINS (comma seperated list)") do |opt|
37
+ @options[:domain] = opt
38
+ end
39
+ end
40
+
41
+ def create_cert
42
+ if @options.key?(:domain)
43
+ if !@options.key?(:dir)
44
+ puts "Directory argument missing"
45
+ exit
46
+ end
47
+
48
+ domain_arr = @options[:domain].split(",")
49
+ dir = @options[:dir]
50
+ cert = Above::Certs.new
51
+ cert.create(domain_arr:, dir:)
52
+ cert.dane(domain_arr:, dir:)
53
+ end
54
+ end
55
+
56
+ def dir(opts)
57
+ opts.on("-d", "--dir DIRECTORY", "Make a certificate in, or look for config in DIRECTORY") do |opt|
58
+ @options[:dir] = opt
59
+ end
20
60
  end
21
61
 
22
62
  def help(opts)
@@ -26,10 +66,20 @@ module Above
26
66
  end
27
67
  end
28
68
 
29
- def options_other(opts)
30
- opts.on("-o", "--other", "Do... other") do |_o|
31
- puts "OK"
32
- exit
69
+ def server(opts)
70
+ opts.on("-s", "--start", "Start the server, if a config directory isn't found one will be created") do |opt|
71
+ @options[:server] = opt
72
+ end
73
+ end
74
+
75
+ def start_server
76
+ return unless @options[:server]
77
+ dir = @options[:dir]
78
+ config = Above::Config.new.read_config(dir:)
79
+ config.each_pair do |server_name, server_hash|
80
+ puts "Starting #{server_name}"
81
+ server = Above::Server.new(config: server_hash)
82
+ server.start
33
83
  end
34
84
  end
35
85
 
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ require_relative "utils"
7
+
8
+ module Above
9
+ # Persist & use configuration
10
+ class Config
11
+ attr_reader :domains
12
+
13
+ def self.config_dir
14
+ File.join(Utils.home_dir, ".config/above")
15
+ end
16
+
17
+ def self.domains_dir
18
+ File.join(Config.config_dir, "domains")
19
+ end
20
+
21
+ def self.exemplar
22
+ {
23
+ "server" => {
24
+ "domain" => "localhost",
25
+ "max_fibers" => 20,
26
+ "port" => 1965,
27
+ "tls_cert" => "/home/above/localhost.crt",
28
+ "tls_key" => "/home/above/localhost.key"
29
+ },
30
+ "middleware" => {
31
+ "Above::MiddleWare::Block" => {
32
+ "blocklist" => [
33
+ "192.168.2.0/24"
34
+ ]
35
+ },
36
+ "Above::MiddleWare::Cache" => {
37
+ "max_size" => 12
38
+ },
39
+ "Above::MiddleWare::Log" => {
40
+ "log_file" => "localhost.log"
41
+ },
42
+ "Above::MiddleWare::Redirect" => {
43
+ "redirect_permanent" => {
44
+ "/from" => "/to"
45
+ },
46
+ "redirect_temporary" => {
47
+ "/from" => "/to"
48
+ }
49
+ },
50
+ "Above::MiddleWare::Static" => {
51
+ "docroot" => "/home/above/www",
52
+ "index" => "index.gmi",
53
+ "lang" => "en"
54
+ }
55
+ }
56
+ }
57
+ end
58
+
59
+ def initialize
60
+ init_dir
61
+ end
62
+
63
+ def init_dir
64
+ if !File.exist?(Config.domains_dir)
65
+ FileUtils.mkdir_p(Config.domains_dir)
66
+ File.write(File.join(Config.config_dir, "example.yml"), Config.exemplar.to_yaml)
67
+ puts "Configuration directory created"
68
+ end
69
+ if Dir.empty?(Config.domains_dir)
70
+ puts "No configuration files found"
71
+ exit
72
+ end
73
+ end
74
+
75
+ def read_config(dir: nil)
76
+ dir ||= Config.domains_dir
77
+ files = Dir[File.join(dir, "**/*.yml")]
78
+ domains = {}
79
+ files.each do |file|
80
+ hash = YAML.safe_load_file(file, permitted_classes: [Hash])
81
+ Utils.check_keys(expected_keys: Config.exemplar.keys, file:, hash:)
82
+ Utils.check_keys(expected_keys: Config.exemplar["server"].keys, file:, hash: hash["server"])
83
+ # Middleware config to be checked in each piece of middleware
84
+ domains[hash["server"]["domain"]] = hash
85
+ end
86
+ if domains.size == 0
87
+ puts "No configuration files found"
88
+ exit
89
+ end
90
+ domains
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Above
6
+ module MiddleWare
7
+ # Middleware to block requests based on IP addresses, also supports IPv6 & /CIDR notation
8
+ class Block
9
+ def initialize(app:)
10
+ @app = app # who you gonna call
11
+ end
12
+
13
+ def call(env:)
14
+ # TODO - maybe return - 41? 44? - chosen via config
15
+ requester = env[:socket].io.remote_address.ip_address
16
+ blocklist = env[:config]["Above::MiddleWare::Block"]["blocklist"]
17
+
18
+ blocklist&.each do |bad_address_range|
19
+ bad_addr = IPAddr.new(bad_address_range)
20
+ if bad_addr.include?(requester)
21
+ # return here, otherwise go on to reply in the usual way
22
+ return [0, "", []]
23
+ end
24
+ end
25
+
26
+ @app&.call(env:)
27
+ rescue IPAddr::InvalidAddressError
28
+ puts "Error - Invalid address in blocklist"
29
+ [42, Status::CODE[42], []]
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # b = Above::MiddleWare::Block.new(app: nil)
36
+ # b.call(env: nil)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Above
6
+ module MiddleWare
7
+ # Middleware for a naive cache that stores the last max_size worth of results in memory
8
+ class Cache
9
+ def initialize(app:)
10
+ @app = app # who you gonna call
11
+ @cache = {}
12
+ @max_size = nil
13
+ end
14
+
15
+ def call(env:)
16
+ # env is {socket:, request:, config:, valid:}
17
+
18
+ # can't cache an invalid url
19
+ return @app&.call(env:) unless env[:valid]
20
+
21
+ if @max_size.nil?
22
+ config = env[:config]["Above::MiddleWare::Cache"]
23
+ @max_size = config["max_size"]
24
+ end
25
+
26
+ request = env[:request].to_s.chomp("/")
27
+ if @cache.key?(request)
28
+ # use the cached result if we have it
29
+ @cache[request][:result]
30
+ else
31
+ # otherwise go on to find & cache the result
32
+ result = @app.call(env:) unless @app.nil?
33
+
34
+ # only keep the most recent @max_size worth of results
35
+ if @cache.size >= @max_size
36
+ oldest = @cache.min_by { |key, val| val[:time] }[0]
37
+ @cache.delete(oldest)
38
+ end
39
+ @cache[request] = {
40
+ result: result,
41
+ time: Time.now
42
+ }
43
+
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../status"
4
+
5
+ module Above
6
+ module MiddleWare
7
+ # Middleware to log replies
8
+ # Apache common log format https://httpd.apache.org/docs/2.4/logs.html#common
9
+ # via Puma https://github.com/puma/puma/blob/master/lib/puma/commonlogger.rb
10
+ #
11
+ # ip - - [time] "request" status size
12
+ # eg
13
+ # ::1 - - [24/Aug/2024:11:20:46 +1000] "gemini://localhost/" 20 143
14
+ class Log
15
+ FORMAT = %(%s - - [%s] "%s" %d %d\n)
16
+ LOG_TIME_FORMAT = "%d/%b/%Y:%H:%M:%S %z"
17
+
18
+ def initialize(app:)
19
+ @app = app # who you gonna call
20
+ @logger = nil
21
+ end
22
+
23
+ def call(env:)
24
+ if @logger.nil?
25
+ config = env[:config]["Above::MiddleWare::Log"]
26
+ # 3 one meg ("mebibyte") files rotated - TODO: chosen via config
27
+ @logger = Logger.new(File.expand_path(config["log_file"]), 3, 1048576)
28
+ end
29
+
30
+ status, header, body_arr = @app.call(env:) unless @app.nil?
31
+
32
+ # TODO: streaming, multipart
33
+
34
+ body = if body_arr.nil? || !body_arr.is_a?(Array) || body_arr.first.nil?
35
+ ""
36
+ else
37
+ body_arr.first
38
+ end
39
+ log(ip: env[:socket].io.remote_address.ip_address,
40
+ request: env[:request],
41
+ status:,
42
+ size: body.size) # body size, not response size
43
+
44
+ [status, header, [body]]
45
+ rescue
46
+ # catch-all for "CGI" errors in the middleware
47
+ [42, Status::CODE[42], []]
48
+ end
49
+
50
+ def log(ip:, request:, status:, size:)
51
+ began_at = Time.now
52
+
53
+ msg = FORMAT % [ip,
54
+ began_at.strftime(LOG_TIME_FORMAT),
55
+ request.to_s,
56
+ status,
57
+ size]
58
+
59
+ write(msg)
60
+ end
61
+
62
+ def write(msg)
63
+ # Standard library logger doesn't support write but it supports << which actually
64
+ # calls to write on the log device without formatting
65
+ if @logger.respond_to?(:write)
66
+ @logger.write(msg)
67
+ else
68
+ @logger << msg
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Above
4
+ module MiddleWare
5
+ # Middleware for redirects
6
+ class Redirect
7
+ def initialize(app:)
8
+ @app = app # who you gonna call
9
+ end
10
+
11
+ def call(env:)
12
+ # env is {socket:, request:, config:, valid:}
13
+ # redirects from local paths to local or remote ones
14
+ # config format is eg:
15
+ # "Above::MiddleWare::Redirect":
16
+ # redirect_permanent:
17
+ # "/bad.txt": "/good.txt"
18
+ # "/sad": "gemini://other.site/" etc
19
+
20
+ # Can't redirect from an invalid url
21
+ return @app&.call(env:) unless env[:valid]
22
+
23
+ request = env[:request].path
24
+ config = env[:config]["Above::MiddleWare::Redirect"]
25
+ permanent_redirects = config["redirect_permanent"]
26
+ temporary_redirects = config["redirect_temporary"]
27
+
28
+ if permanent_redirects.key?(request)
29
+ return [30, permanent_redirects[request], []]
30
+ elsif temporary_redirects.key?(request)
31
+ return [30, temporary_redirects[request], []]
32
+ end
33
+
34
+ # Otherise pass it on
35
+ status, header, body_arr = @app.call(env:)
36
+ [status, header, body_arr]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Above
4
+ module MiddleWare
5
+ # Middleware to serve static files
6
+ class Static
7
+ def initialize(app:)
8
+ @app = app # who you gonna call
9
+ end
10
+
11
+ def call(env:)
12
+ # env is {socket:, request:, config:, server:, valid:}
13
+
14
+ status, header, body_arr = @app.call(env:) unless @app.nil? # usually nothing to call from here
15
+
16
+ # If redirect or cache fired we wouldn't get to here in the default arrangemnet. Usually wouldn't have any
17
+ # other middleware to call after this one. If indeed we don't, or if that didn't create a response, try to get the file requested
18
+ if status.nil?
19
+ response(env:)
20
+ else
21
+ [status, header, body_arr]
22
+ end
23
+ end
24
+
25
+ def format_response(maybe_incomplete_triplet:, env:)
26
+ lang = env[:config]["Above::MiddleWare::Static"]["lang"]
27
+ status, header, content_arr = maybe_incomplete_triplet
28
+ if status < 20 || status > 59
29
+ # TODO: input requests (from some other middleware?) not currently handled
30
+ [42, Status::CODE[42], []]
31
+ elsif status > 39
32
+ # Ensure errors have some header text
33
+ header = Status::CODE[status] if header.nil?
34
+ [status, header, content_arr]
35
+ elsif header.start_with?("text/")
36
+ # TODO: multipart, streaming etc
37
+ # header here is the mime_type, from #read_file
38
+ content = content_arr.first.encode(Encoding.find("UTF-8"), invalid: :replace, undef: :replace, replace: "")
39
+ [status, "#{header}; charset=utf-8; lang=#{lang}; size=#{content.length}", [content]]
40
+ else
41
+ [status, "#{header}; size=#{content_arr.first.length}", content_arr]
42
+ end
43
+ end
44
+
45
+ def read_file(request:, env:, again: false)
46
+ config = env[:config]["Above::MiddleWare::Static"]
47
+ path = File.join(File.expand_path(config["docroot"]), request.path)
48
+ mime_type = Mime.type(path:) # TODO - maybe replace marcel, this reads files twice...
49
+ content = File.read(path)
50
+ # Maybe TODO: multi part, streaming etc
51
+ # nb header is incomplete at this point
52
+ [20, mime_type, [content]]
53
+ rescue Errno::EISDIR
54
+ if !again
55
+ try_index(request:, env:, index: config["index"])
56
+ else
57
+ [51, Status::CODE[51], []]
58
+ end
59
+ rescue Errno::ENOENT
60
+ [51, Status::CODE[51], []]
61
+ end
62
+
63
+ def response(env:)
64
+ host = env[:server]["domain"]
65
+ request = env[:request]
66
+ response = if !env[:valid]
67
+ # Bad request caught earlier
68
+ [59, Status::CODE[59], []]
69
+ elsif request.to_s.length > 1024
70
+ # Request longer than maximum length
71
+ [59, Status::CODE[59], []]
72
+ elsif request.scheme != "gemini" || request.host != host
73
+ # Currently - refuse proxy requests for different protocols or servers, maybe TODO
74
+ [53, Status::CODE[53], []]
75
+ else
76
+ maybe_incomplete_triplet = read_file(request:, env:)
77
+ format_response(maybe_incomplete_triplet:, env:)
78
+ end
79
+ rescue NoMethodError
80
+ # eg trying to encode nil content
81
+ response = [40, Status::CODE[40], []]
82
+ rescue
83
+ # Bad uri, no scheme etc that got this far
84
+ response = [59, Status::CODE[59], []]
85
+ ensure
86
+ response
87
+ end
88
+
89
+ def try_index(request:, env:, index:)
90
+ new_request = request.dup
91
+ new_request.path = File.join(request.path, index)
92
+ read_file(request: new_request, env:, again: true)
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/above/mime.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "marcel"
4
+ require "pathname"
5
+
6
+ module Above
7
+ # Mime types
8
+ module Mime
9
+ # Mime type based on extension for Gemtext files &
10
+ # Marcel for everything else
11
+ # throws Errno::ENOENT on missing non gemtext files
12
+ # Marcel::MimeType.extend "text/gemini", extensions: %w[.gmi .gemini] doesn't seem to work?
13
+ def type(path:)
14
+ if %w[.gmi .gemini].include? File.extname(path)
15
+ "text/gemini"
16
+ else
17
+ name = File.basename(path)
18
+ Marcel::MimeType.for(Pathname.new(path), name: name)
19
+ end
20
+ end
21
+
22
+ module_function :type
23
+ end
24
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/barrier"
5
+ require "async/semaphore"
6
+ require "logger"
7
+ require "openssl"
8
+ require "uri"
9
+
10
+ require_relative "config"
11
+ require_relative "middleware/block"
12
+ require_relative "middleware/cache"
13
+ require_relative "middleware/log"
14
+ require_relative "middleware/redirect"
15
+ require_relative "middleware/static"
16
+ require_relative "mime"
17
+ require_relative "status"
18
+
19
+ module Above
20
+ # Answer TCP requests
21
+ class Server
22
+ def initialize(config:, middleware: [MiddleWare::Log,
23
+ MiddleWare::Block,
24
+ MiddleWare::Cache,
25
+ MiddleWare::Redirect,
26
+ MiddleWare::Static])
27
+ @config = config
28
+ @server_config = config["server"]
29
+ middleware_init(middleware:)
30
+ end
31
+
32
+ def listener(tls:, limit:)
33
+ limit.async do
34
+ socket = tls.accept
35
+ # env is {socket:, request:, config:, valid:}
36
+ env = request(socket:)
37
+ response = middleware(env:)
38
+ reply(env:, response:)
39
+ rescue => error
40
+ puts error.message
41
+ # may be unable to reply if error is with socket
42
+ # TODO: logging at a level above the logging middleware
43
+ ensure
44
+ socket&.close
45
+ end
46
+ end
47
+
48
+ # Instantiate each middleware class with an app vairable pointing to the
49
+ # next piece of middleware to call
50
+ def middleware_init(middleware:)
51
+ @middleware = []
52
+ previous = nil
53
+ middleware.reverse_each do |mid|
54
+ instance = mid.new(app: previous)
55
+ previous = instance
56
+ @middleware.push(instance)
57
+ end
58
+ @middleware.reverse!
59
+ end
60
+
61
+ def middleware(env:)
62
+ # Each piece of middleware will call the next, the result here is
63
+ # [status, header, [body]], just like in rack
64
+ @middleware.first.call(env:)
65
+ rescue
66
+ # TODO - log this
67
+ [42, Status::CODE[42], []]
68
+ end
69
+
70
+ def on_quit(barrier:)
71
+ %w[INT TERM].each do |signal|
72
+ Signal.trap(signal) do
73
+ barrier.stop
74
+ puts "\nShutting down"
75
+ exit
76
+ end
77
+ end
78
+ end
79
+
80
+ def reply(env:, response:)
81
+ # TODO: uncertain if there's meant to be a space before the \r\n
82
+ # mentioned in https://geminiprotocol.net/news/2024_07_30.gmi
83
+ # Maybe TODO: streaming, multipart replies etc
84
+ status, header, body_arr = response
85
+ if status === 0
86
+ # blocked
87
+ env[:socket]&.close
88
+ return
89
+ elsif status.nil?
90
+ status = 40
91
+ header = Status::CODE[40]
92
+ end
93
+ body = if body_arr.nil? || !body_arr.is_a?(Array) || body_arr.first.nil?
94
+ ""
95
+ else
96
+ body_arr.first
97
+ end
98
+ env[:socket].puts "#{status} #{header}\r\n#{body}"
99
+ rescue
100
+ env[:socket].puts "40 #{Status::CODE[40]}\r\n"
101
+ end
102
+
103
+ # creates env hash, {socket:, request:, config:, server:, valid:}
104
+ def request(socket:)
105
+ config = @config["middleware"]
106
+ str = socket.gets.chomp
107
+ request = URI(str)
108
+ {socket:, request:, config:, server: @server_config, valid: true}
109
+ rescue URI::InvalidURIError
110
+ {socket:, request: str[0...1023], config:, server: @server_config, valid: false}
111
+ end
112
+
113
+ def serve(tls:)
114
+ barrier = Async::Barrier.new
115
+ Sync do
116
+ on_quit(barrier:)
117
+ limit = Async::Semaphore.new(@server_config["max_fibers"], parent: barrier)
118
+ loop do
119
+ listener(tls:, limit:)
120
+ end
121
+ ensure
122
+ barrier.stop
123
+ end
124
+ end
125
+
126
+ def start
127
+ serve(tls: tls_server)
128
+ end
129
+
130
+ def tls_server
131
+ tcp_server = TCPServer.new(@server_config["domain"], @server_config["port"])
132
+ ssl_ctx = OpenSSL::SSL::SSLContext.new.tap do |ctx|
133
+ ctx.key = OpenSSL::PKey::EC.new(
134
+ File.read(
135
+ File.expand_path(
136
+ @server_config["tls_key"]
137
+ )
138
+ )
139
+ )
140
+ ctx.cert = OpenSSL::X509::Certificate.new(
141
+ File.read(
142
+ File.expand_path(
143
+ @server_config["tls_cert"]
144
+ )
145
+ )
146
+ )
147
+ end
148
+ OpenSSL::SSL::SSLServer.new(tcp_server, ssl_ctx)
149
+ rescue => error
150
+ puts "Certificate/Key loading errors - #{error.message}"
151
+ exit
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Above
4
+ # Gemini status codes, includes non standard codes 0-9, otherwise
5
+ # from https://geminiprotocol.net/docs/protocol-specification.gmi
6
+ module Status
7
+ CODE = {
8
+ # 0-9 Non standard codes
9
+ 0 => "Address blocked",
10
+
11
+ # 10-19 Input expected
12
+ 10 => "User input requested",
13
+ 11 => "Sensitive user input requested",
14
+
15
+ # 20-29 Success
16
+ 20 => "Success",
17
+
18
+ # 30-39 Redirection
19
+ 30 => "Temporary redirection",
20
+ 31 => "Permanent redirection",
21
+
22
+ # 40+ descriptions are included in the header
23
+ # 40-49 Temporary failure
24
+ 40 => "Temporary failure",
25
+ 41 => "Server unavailable",
26
+ 42 => "CGI error",
27
+ 43 => "Proxy error from/with the proxied server",
28
+ 44 => "Slow down",
29
+
30
+ # 50-59 Permanent failure
31
+ 50 => "General permanent failure",
32
+ 51 => "Not found",
33
+ 52 => "Gone permanently",
34
+ 53 => "Proxy request refused by this server",
35
+ 59 => "Bad request",
36
+
37
+ # 60-69 Client certificates
38
+ 60 => "Content requires a client certificate",
39
+ 61 => "Certificate not authorized",
40
+ 62 => "Certificate not valid (unrelated to request)"
41
+ }
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Above
4
+ # Utility functions
5
+ module Utils
6
+ def check_keys(expected_keys:, file:, hash:)
7
+ if !expected_keys.all? { |key| hash.key? key }
8
+ puts "Missing keys - #{file}"
9
+ (expected_keys - hash.keys).each { |key| puts key }
10
+ exit
11
+ end
12
+ end
13
+
14
+ def home_dir
15
+ path = if RUBY_PLATFORM.match?(/cygwin | mswin | mingw | bccwin | wince | emx /)
16
+ "%userprofile%"
17
+ else
18
+ "~"
19
+ end
20
+ File.expand_path(path)
21
+ end
22
+
23
+ module_function :check_keys, :home_dir
24
+ end
25
+ end
data/lib/above/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Above
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/above.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "above/config"
4
+ require_relative "above/certs"
5
+ require_relative "above/server"
3
6
  require_relative "above/version"
4
7
 
5
8
  # Above's top level module
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: above
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Kreuzer
@@ -35,11 +35,54 @@ cert_chain:
35
35
  YgqTixCwts6cQYIdYNFtJbzKvRNqyviKKxPaum7UWAv6Uy80gxgJ8p+fG81FsxbZ
36
36
  0ULnXrHlhf/CHs550TxRlXalgxBCImGdHzWjhdJeC1dZP3olgNtjO23ywtw=
37
37
  -----END CERTIFICATE-----
38
- date: 2024-08-13 00:00:00.000000000 Z
39
- dependencies: []
40
- description: 'A Gemini Protocol Server - initial commit only
38
+ date: 2024-08-27 00:00:00.000000000 Z
39
+ dependencies:
40
+ - !ruby/object:Gem::Dependency
41
+ name: async
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.15'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.15'
54
+ - !ruby/object:Gem::Dependency
55
+ name: marcel
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: openssl
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.2'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.2'
82
+ description: |
83
+ An async Gemini Protocol server with support for multi-tenancy.
41
84
 
42
- '
85
+ Above supports middleware, with default middleware for blocking, naive in-memory caching, logging, redirects, & a static file server included. Also included are utilities for certificate creation & DNS-based authentication (DANE).
43
86
  email:
44
87
  - mike@mikekreuzer.com
45
88
  executables:
@@ -54,16 +97,27 @@ files:
54
97
  - bin/console
55
98
  - bin/setup
56
99
  - lib/above.rb
100
+ - lib/above/certs.rb
57
101
  - lib/above/cli.rb
102
+ - lib/above/config.rb
103
+ - lib/above/middleware/block.rb
104
+ - lib/above/middleware/cache.rb
105
+ - lib/above/middleware/log.rb
106
+ - lib/above/middleware/redirect.rb
107
+ - lib/above/middleware/static.rb
108
+ - lib/above/mime.rb
109
+ - lib/above/server.rb
110
+ - lib/above/status.rb
111
+ - lib/above/utils.rb
58
112
  - lib/above/version.rb
59
- homepage: https://codeberg.org/kreuzer/kreuzer/above
113
+ homepage: https://codeberg.org/kreuzer/above
60
114
  licenses:
61
115
  - AGPL-3.0-or-later
62
116
  metadata:
63
- changelog_uri: https://codeberg.org/kreuzer/kreuzer/above/src/branch/main/CHANGELOG.md
64
- homepage_uri: https://codeberg.org/kreuzer/kreuzer/above
117
+ changelog_uri: https://codeberg.org/kreuzer/above/src/branch/main/CHANGELOG.md
118
+ homepage_uri: https://codeberg.org/kreuzer/above
65
119
  rubygems_mfa_required: 'true'
66
- source_code_uri: https://codeberg.org/kreuzer/kreuzer/above
120
+ source_code_uri: https://codeberg.org/kreuzer/above
67
121
  post_install_message:
68
122
  rdoc_options: []
69
123
  require_paths:
@@ -82,5 +136,5 @@ requirements: []
82
136
  rubygems_version: 3.5.17
83
137
  signing_key:
84
138
  specification_version: 4
85
- summary: A Gemini Protocol Server - initial commit only
139
+ summary: A Gemini Protocol server
86
140
  test_files: []
metadata.gz.sig CHANGED
Binary file