above 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73c14594eb00a337f64a37313c2105a4e2075c372b1e22e2c5a6d681d5b0a8a3
4
- data.tar.gz: '093ba806cb64f9e6283d66c4273cd1355f5d5620f7f71460acc6bdc3f76961ae'
3
+ metadata.gz: f7e51e2b89987edc6d43589b7f0cd54c1ac4f5a7fd7e220c563ec468c85f8ea0
4
+ data.tar.gz: bb2b4bb89b3a3c23d4e2ba91990c65eae13ce516c69600ce166d7c122a4f1bba
5
5
  SHA512:
6
- metadata.gz: 8c4a24b30c8f5fc8d99114486f678021e590924aa0e3565fe7d4d59089fafbb715724e609ef41cae089a8acd3a77f5f637ede07c8053415dd1a26c1498741be3
7
- data.tar.gz: 1e10259b6b5e3c39c6bf9395903b059d91c08f77f84173308f7aeaf65b3284fc066e387abba530c0d2319c5810091fa070d96310215968fddf4d35185643751e
6
+ metadata.gz: 98f9509eccf23022d3d3d05e070c627e175008e4b2adb6951cc822d204e44b0eb459adbfe4753197fd79d1bdb7d0dece715b4ea1e4a808543862540cebf03eff
7
+ data.tar.gz: 242ae0538a1c634ddfd2ab6ece8ff9b8061813da931f896be0195424b6f88668204433630c51dd4e5d210993821ef4491122293861841a81e99a6e0da9198913
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
- ## [Unreleased]
1
+ # Above Changelog
2
2
 
3
- ## [0.1.0] - 13 August 2024
3
+ ## 0.2.0 - 24 August 2024
4
4
 
5
5
  - Initial release
6
+
7
+ ## 0.1.0 - 13 August 2024
8
+
9
+ - 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.2.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.2.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-24 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,7 +97,18 @@ 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
113
  homepage: https://codeberg.org/kreuzer/kreuzer/above
60
114
  licenses:
@@ -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
@@ -1,4 +1,2 @@
1
- !i��2=��q<����0��q�+d�?�gF��mf��w��
2
- ��3VB����x�<�,��ݼ�ΩT�bPˊ1�=�x!��Je�I[Ơ�4u,>���G'�毞=� �'S>t��!�B�Z�D��0oU8�W/���Z ��X�T�C���>��}Gӧb_a3�=�������z���w\
3
- ��hL�T��r?)���g��츗�}N�W�ԙl:��D2��%t�6̯LRo�^�!��w)�U
4
- b���4xV����G϶g��yIeSw\���q�>���� �
1
+ e�we�'歂].8���5�t�1-�9�E�au�7Ʌ䆮f�lf�h��#��V��G���O�����<a���ۄI'߯I�q�vh�Hs;�+��u����p;�H�\1v`�S���@��$�tvn+jw��I��S�����ciEh�W���'�8;x�AD��/��fY=�������.�8 �m�j����)s��.��+�P-ҍ'g`7L��9.�e��P��}�+���9�d�O�5�6ޯ��������UG�'!)�F\q��h��Q���4
2
+ @^