above 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
@^
|