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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +6 -2
- data/README.md +19 -8
- data/lib/above/certs.rb +70 -0
- data/lib/above/cli.rb +56 -6
- data/lib/above/config.rb +93 -0
- data/lib/above/middleware/block.rb +36 -0
- data/lib/above/middleware/cache.rb +49 -0
- data/lib/above/middleware/log.rb +73 -0
- data/lib/above/middleware/redirect.rb +40 -0
- data/lib/above/middleware/static.rb +96 -0
- data/lib/above/mime.rb +24 -0
- data/lib/above/server.rb +154 -0
- data/lib/above/status.rb +43 -0
- data/lib/above/utils.rb +25 -0
- data/lib/above/version.rb +1 -1
- data/lib/above.rb +3 -0
- data.tar.gz.sig +0 -0
- metadata +60 -6
- metadata.gz.sig +2 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f7e51e2b89987edc6d43589b7f0cd54c1ac4f5a7fd7e220c563ec468c85f8ea0
|
4
|
+
data.tar.gz: bb2b4bb89b3a3c23d4e2ba91990c65eae13ce516c69600ce166d7c122a4f1bba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 98f9509eccf23022d3d3d05e070c627e175008e4b2adb6951cc822d204e44b0eb459adbfe4753197fd79d1bdb7d0dece715b4ea1e4a808543862540cebf03eff
|
7
|
+
data.tar.gz: 242ae0538a1c634ddfd2ab6ece8ff9b8061813da931f896be0195424b6f88668204433630c51dd4e5d210993821ef4491122293861841a81e99a6e0da9198913
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,20 +1,31 @@
|
|
1
1
|
# Above
|
2
2
|
|
3
|
-
|
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
|
-
##
|
5
|
+
## Status
|
6
6
|
|
7
|
-
|
7
|
+
3 - Alpha in Python terms.
|
8
8
|
|
9
|
-
|
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
|
-
##
|
16
|
+
## Why
|
14
17
|
|
15
|
-
|
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
|
-
|
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
|
|
data/lib/above/certs.rb
ADDED
@@ -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
|
-
|
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
|
-
|
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
|
30
|
-
opts.on("-
|
31
|
-
|
32
|
-
|
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
|
|
data/lib/above/config.rb
ADDED
@@ -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
|
data/lib/above/server.rb
ADDED
@@ -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
|
data/lib/above/status.rb
ADDED
@@ -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
|
data/lib/above/utils.rb
ADDED
@@ -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
data/lib/above.rb
CHANGED
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.
|
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-
|
39
|
-
dependencies:
|
40
|
-
|
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
|
139
|
+
summary: A Gemini Protocol server
|
86
140
|
test_files: []
|
metadata.gz.sig
CHANGED
@@ -1,4 +1,2 @@
|
|
1
|
-
|
2
|
-
|
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
|
+
@^
|