koi-server 1.0.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 +7 -0
- data/.gemspec +22 -0
- data/.gitignore +2 -0
- data/Gemfile +5 -0
- data/README.org +103 -0
- data/bin/koi +87 -0
- data/lib/koi/erb/handler.rb +118 -0
- data/lib/koi/erb.rb +6 -0
- data/lib/koi/file.rb +95 -0
- data/lib/koi/gemini/request.rb +60 -0
- data/lib/koi/gemini/response.rb +15 -0
- data/lib/koi/gemini/status.rb +31 -0
- data/lib/koi/gemini.rb +6 -0
- data/lib/koi/server.rb +91 -0
- data/lib/koi/ssl.rb +67 -0
- data/lib/koi.rb +19 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1ae15672990b7868474ae97997cb5938dfdc0bae534de6568749aad8ad1f111f
|
|
4
|
+
data.tar.gz: 3889154d8e5bf470942ef2af98657c3c4cf7b24561c8a11428b903c3108ad692
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 31e9220a59089665ec0bcdd807ab2b87ddd749e3e52b9ba818e6f5ef564a4bd940ad4f4520e000638670d2419f4d5445d8e8bf67423d79f40e33714589ff6224
|
|
7
|
+
data.tar.gz: 044e02890146c4c86aa7b397b14d79acf3bcf9398f38cf61d5566ad473a3b99a78f3ecccc086b1a7e2017de1697da8500d4a9c1d6484f9bc8ba02b19ecf1c62f
|
data/.gemspec
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Gem::Specification.new do |s|
|
|
2
|
+
s.name = 'koi-server'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.licenses = ['MIT']
|
|
5
|
+
s.summary = "Koi is a ruby-based Gemini server that supports ERB templating."
|
|
6
|
+
s.description = "Koi is a ruby-based Gemini server that supports .erb templating, and static files, and handles TLS certs for you.
|
|
7
|
+
|
|
8
|
+
Koi is designed to make hosting Gemini CGI pages a joyful and
|
|
9
|
+
tranquil experience, uniting the conceptual beauty of a simple web
|
|
10
|
+
with developer happiness.
|
|
11
|
+
|
|
12
|
+
By using a combination of Ruby powered ERB templating and
|
|
13
|
+
traditional static file serving we distill a solution offering
|
|
14
|
+
the best of both worlds.
|
|
15
|
+
"
|
|
16
|
+
s.authors = ["Justin Woodring"]
|
|
17
|
+
s.email = 'jwoodrg@gmail.com'
|
|
18
|
+
s.files = `git ls-files -z`.split("\x0")
|
|
19
|
+
s.executables << "koi"
|
|
20
|
+
s.homepage = 'https://rubygems.org/gems/koi'
|
|
21
|
+
s.metadata = { "source_code_uri" => "https://github.com/JustinWoodring/Koi" }
|
|
22
|
+
end
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.org
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#+title: Koi Gemini Server
|
|
2
|
+
#+subtitle: A Gemini server that serves templated static .erb files.
|
|
3
|
+
#+author: Justin Woodring
|
|
4
|
+
#+language: en
|
|
5
|
+
|
|
6
|
+
Koi is another ruby-based Gemini server that supports .erb templating,
|
|
7
|
+
and static files, and handles TLS certs for you.
|
|
8
|
+
|
|
9
|
+
* Ethos
|
|
10
|
+
|
|
11
|
+
Koi is designed to make hosting Gemini CGI pages a joyful and
|
|
12
|
+
tranquil experience, uniting the conceptual beauty of a simple web
|
|
13
|
+
with developer happiness.
|
|
14
|
+
|
|
15
|
+
By using a combination of Ruby powered ERB templating and
|
|
16
|
+
traditional static file serving we distill a solution offering
|
|
17
|
+
the best of both worlds.
|
|
18
|
+
|
|
19
|
+
* How it works
|
|
20
|
+
|
|
21
|
+
This server operates in every capacity as a static file server,
|
|
22
|
+
unless the file it is serving begins with . i.e. hidden files or
|
|
23
|
+
the file ends a .erb extension.
|
|
24
|
+
|
|
25
|
+
Files ending with a .erb extension use the ERB markup syntax to
|
|
26
|
+
generate responses that are assumed to be `gemtext`, under most
|
|
27
|
+
circumstances. That said, each file is executed with it's directory
|
|
28
|
+
as the PWD and it injects binds the following into the ERB environment:
|
|
29
|
+
|
|
30
|
+
** Request: Object = Gemini Request Received
|
|
31
|
+
*** protocol: String - should always be 'gemini://'
|
|
32
|
+
*** domain: String - the domain name in the absolute URI
|
|
33
|
+
*** path: String - absolute file path relative to served root
|
|
34
|
+
*** query: String - what follows ? in the absolute URI (url_encoded)
|
|
35
|
+
|
|
36
|
+
** response_status: Integer = nil
|
|
37
|
+
These object can be set to override default gemtext generation. This
|
|
38
|
+
particular property changes the response code with the raw Gemini code
|
|
39
|
+
for a given response e.g. 10 is input required.
|
|
40
|
+
|
|
41
|
+
** response_metadata: String = nil
|
|
42
|
+
This is the metadata object of the response that can be overridden to
|
|
43
|
+
supply supplementary info for a given response. e.g. a redirect URL,
|
|
44
|
+
a new content type header, a query description text.
|
|
45
|
+
|
|
46
|
+
** response_content: String = nil
|
|
47
|
+
This can be used to return arbitrary content and overrides what .erb
|
|
48
|
+
evaluates to if set. You could theoretically generate and serve images
|
|
49
|
+
from an .erb by setting this property with the correct mime type
|
|
50
|
+
set in the metadata.
|
|
51
|
+
|
|
52
|
+
Here is a contrived example that demonstates the power of ERB templating
|
|
53
|
+
in this context:
|
|
54
|
+
|
|
55
|
+
#+NAME: /posts/index.erb
|
|
56
|
+
#+BEGIN_SRC erb
|
|
57
|
+
# Search my posts
|
|
58
|
+
|
|
59
|
+
<% if request.query == "" %>
|
|
60
|
+
<% @response_status = 10 %>
|
|
61
|
+
<% @response_metadata = "Enter a filter term" %>
|
|
62
|
+
<% end %>
|
|
63
|
+
|
|
64
|
+
<% `ls`.split("\n").select {|x| x.start_with?(@request.query) and x.end_with?(".gmi")}.each do |item| %>
|
|
65
|
+
=> gemini://mysite.com/posts/<%= item %>
|
|
66
|
+
<% end %>
|
|
67
|
+
#+END_SRC
|
|
68
|
+
|
|
69
|
+
* Running Your Own Server
|
|
70
|
+
|
|
71
|
+
Running your own server is as simple as installing one gem: `koi`
|
|
72
|
+
and pointing it at a cert and key that you might even generate
|
|
73
|
+
using `koi`.
|
|
74
|
+
|
|
75
|
+
#+begin_example
|
|
76
|
+
|
|
77
|
+
# Defaults are -s nil -b 0.0.0.0 -p 1965 -e (.erb enabled)
|
|
78
|
+
|
|
79
|
+
koi -s /home/user/public_gmi
|
|
80
|
+
|
|
81
|
+
OR
|
|
82
|
+
|
|
83
|
+
koi -s /home/user/public_gmi -b 0.0.0.0 -p 1965
|
|
84
|
+
|
|
85
|
+
OR
|
|
86
|
+
|
|
87
|
+
koi -s /home/user/public_gmi -b 0.0.0.0 -p 1965 --no-erb
|
|
88
|
+
|
|
89
|
+
OR
|
|
90
|
+
|
|
91
|
+
koi -s /home/user/public_gmi -b 0.0.0.0 -p 1965 -e -k key.pem -c cert.pem
|
|
92
|
+
|
|
93
|
+
FOR CERTIFICATE GENERATION
|
|
94
|
+
|
|
95
|
+
koi -g COMMONNAME
|
|
96
|
+
#+end_example
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
* No AI Notice
|
|
100
|
+
|
|
101
|
+
No AI tools were used at any point during developemnt or
|
|
102
|
+
documentation. This project was expressly 100% hand-written
|
|
103
|
+
by a human.
|
data/bin/koi
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative '../lib/koi.rb'
|
|
6
|
+
|
|
7
|
+
# Declare defaults
|
|
8
|
+
config = {
|
|
9
|
+
bind_addr: "0.0.0.0", # Bind All Available Interfaces
|
|
10
|
+
bind_port: "1965", # Default Gemini Port
|
|
11
|
+
file_root_dir: nil, # No Default
|
|
12
|
+
erb_enabled: true # Assume Yes
|
|
13
|
+
key_file_path: nil
|
|
14
|
+
cert_file_path: nil
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Declare parse options
|
|
19
|
+
parser = OptionParser.new do |opts|
|
|
20
|
+
opts.banner = "Usage:\tkoi [options]\n\tkoi -s ~/public_gmi -b '0.0.0.0' -p 1965 -e true"
|
|
21
|
+
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Specific options:"
|
|
24
|
+
|
|
25
|
+
opts.on("-s", "--serve FILE_ROOT", String, "Required. File root of public .gmi and .erb files") do |v|
|
|
26
|
+
config[:file_root_dir] = v
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
opts.on("-b", "--bind_addr ADDR", String, "Bind address for the server to listen on") do |v|
|
|
30
|
+
config[:bind_addr] = v
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts.on("-p", "--port PORT", Integer, "Bind port for the server to listen on") do |v|
|
|
34
|
+
config[:bind_port] = v
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
opts.on("-e", "--[no]-erb", "Enable serving .erb files") do |v|
|
|
38
|
+
config[:erb_enabled] = v
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
opts.separator "Certificate options, if either is unset the server will generate a throwaway self-signed cert on-demand."
|
|
42
|
+
|
|
43
|
+
opts.on("-k", "--key KEY_FILE_PATH", String, "Path to your private key.") do |v|
|
|
44
|
+
config[:key_file_path] = v
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
opts.on("-c", "--cert CERT_FILE_PATH", String, "Path to your certificate.") do |v|
|
|
48
|
+
config[:cert_file_path] = v
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
opts.separator ""
|
|
52
|
+
opts.separator "Common options:"
|
|
53
|
+
|
|
54
|
+
opts.on_tail("-g", "--generate-cert CN", "Generate a self-signed certficate") do |cn|
|
|
55
|
+
key, cert = Koi::SSL.generate_key_and_cert(cn)
|
|
56
|
+
File.write('koi_private_key.pem', key.to_pem)
|
|
57
|
+
File.write('koi_certificate.pem', cert.to_pem)
|
|
58
|
+
|
|
59
|
+
puts "Key written to koi_private_key.pem successfully!"
|
|
60
|
+
puts "Key written to koi_certificate.pem successfully!"
|
|
61
|
+
exit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on_tail("-h", "--help", "Prints this help") do
|
|
65
|
+
puts parser
|
|
66
|
+
exit
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse output and validate required fields.
|
|
71
|
+
puts parser.parse!(ARGV)
|
|
72
|
+
|
|
73
|
+
if config[:file_root_dir] == nil then
|
|
74
|
+
puts parser
|
|
75
|
+
exit
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run the Koi server!
|
|
79
|
+
|
|
80
|
+
koi = Koi::Pond.new(config)
|
|
81
|
+
koi.run
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'open3'
|
|
3
|
+
|
|
4
|
+
require_relative '../gemini'
|
|
5
|
+
|
|
6
|
+
class Koi::ERB::Handler
|
|
7
|
+
def initialize
|
|
8
|
+
# We might put properties that can defined here in the future.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def handler_internal(filename, request)
|
|
12
|
+
ruby_code = <<~RUBY
|
|
13
|
+
require 'erb'
|
|
14
|
+
|
|
15
|
+
class Koi
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Koi::ERB
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Koi::ERB::Context
|
|
22
|
+
@request = nil
|
|
23
|
+
@response_status = nil
|
|
24
|
+
@response_metadata = nil
|
|
25
|
+
@response_content = nil
|
|
26
|
+
|
|
27
|
+
attr_accessor :request, :response_status, :response_metadata, :response_content
|
|
28
|
+
|
|
29
|
+
def initialize(request)
|
|
30
|
+
@request = request
|
|
31
|
+
@response_status = nil
|
|
32
|
+
@response_metadata = nil
|
|
33
|
+
@response_content = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get_binding
|
|
37
|
+
return binding
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Koi::Gemini
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Koi::Gemini::Request
|
|
45
|
+
attr_accessor :is_valid, :protocol, :domain, :path, :query
|
|
46
|
+
|
|
47
|
+
def initialize(is_valid, protocol, domain, path, query)
|
|
48
|
+
@is_valid = is_valid
|
|
49
|
+
@protocol = protocol
|
|
50
|
+
@domain = domain
|
|
51
|
+
@path = path
|
|
52
|
+
@query = query
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
request = Koi::Gemini::Request.new(
|
|
57
|
+
"#{request.is_valid}",
|
|
58
|
+
'#{request.protocol.to_s.dump}'.undump,
|
|
59
|
+
'#{request.domain.to_s.dump}'.undump,
|
|
60
|
+
'#{request.path.to_s.dump}'.undump,
|
|
61
|
+
'#{request.query.to_s.dump}'.undump)
|
|
62
|
+
|
|
63
|
+
template_content = File.binread('#{filename.dump}'.undump)
|
|
64
|
+
context = Koi::ERB::Context.new(request)
|
|
65
|
+
erb = ERB.new(template_content)
|
|
66
|
+
|
|
67
|
+
output = erb.result(context.get_binding)
|
|
68
|
+
|
|
69
|
+
status = if context.response_status != nil
|
|
70
|
+
context.response_status else 20 end
|
|
71
|
+
metadata = if context.response_metadata != nil
|
|
72
|
+
context.response_metadata else
|
|
73
|
+
"text/gemini; charset=utf-8" end
|
|
74
|
+
content = if context.response_content != nil
|
|
75
|
+
context.response_content else
|
|
76
|
+
output.b end
|
|
77
|
+
|
|
78
|
+
puts status
|
|
79
|
+
puts metadata
|
|
80
|
+
puts content
|
|
81
|
+
RUBY
|
|
82
|
+
|
|
83
|
+
return ruby_code
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle(valid_path, request)
|
|
87
|
+
begin
|
|
88
|
+
# Initialize ERB
|
|
89
|
+
filename = valid_path[valid_path.rindex('/')+1..-1]
|
|
90
|
+
path_prefix = valid_path[0..valid_path.rindex('/')]
|
|
91
|
+
|
|
92
|
+
stdout, stderr, code = Open3.capture3("ruby",
|
|
93
|
+
chdir: valid_path[0..valid_path.rindex('/')],
|
|
94
|
+
stdin_data: handler_internal(filename, request))
|
|
95
|
+
|
|
96
|
+
output = stdout.split("\n",3)
|
|
97
|
+
|
|
98
|
+
puts "---\n#{stdout}\n---"
|
|
99
|
+
puts "---\n#{stderr}\n---"
|
|
100
|
+
|
|
101
|
+
status = output[0]
|
|
102
|
+
metadata = output[1]
|
|
103
|
+
content = output[2]
|
|
104
|
+
|
|
105
|
+
return Koi::Gemini::Response.new(status, metadata, content)
|
|
106
|
+
rescue Exception => e
|
|
107
|
+
status = Koi::Gemini::Status::PermanentGenericError
|
|
108
|
+
metadata = "CGI backend experienced an error."
|
|
109
|
+
content = nil
|
|
110
|
+
|
|
111
|
+
puts e
|
|
112
|
+
|
|
113
|
+
return Koi::Gemini::Response.new(status, metadata, content)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
|
data/lib/koi/erb.rb
ADDED
data/lib/koi/file.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require_relative "./gemini"
|
|
2
|
+
require_relative "./erb"
|
|
3
|
+
|
|
4
|
+
class FileServer
|
|
5
|
+
@base_directory = nil
|
|
6
|
+
@erb_enabled = false
|
|
7
|
+
@erb_handler = nil
|
|
8
|
+
|
|
9
|
+
def initialize(base_directory, erb_enabled)
|
|
10
|
+
@base_directory = base_directory
|
|
11
|
+
@erb_enabled = erb_enabled
|
|
12
|
+
if erb_enabled then
|
|
13
|
+
@erb_handler = Koi::ERB::Handler.new
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def search_path(path)
|
|
18
|
+
files = Dir.glob("#{@base_directory}/**/*").each do |f|
|
|
19
|
+
f.sub!("#{@base_directory}", "")
|
|
20
|
+
end.append("/")
|
|
21
|
+
|
|
22
|
+
f_trimmed = files.find { |file| path == file }
|
|
23
|
+
|
|
24
|
+
if f_trimmed != nil then
|
|
25
|
+
if File.directory?(File.join("#{@base_directory}", f_trimmed)) then
|
|
26
|
+
# If we are in a directory lets test for an index.erb and then index.gmi
|
|
27
|
+
|
|
28
|
+
if @erb_enabled then
|
|
29
|
+
i = File.join("#{@base_directory}", f_trimmed, "index.erb")
|
|
30
|
+
|
|
31
|
+
if File.exist?(i) then
|
|
32
|
+
return i
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
i = File.join("#{@base_directory}", f_trimmed, "index.gmi")
|
|
37
|
+
if File.exist?(i) then
|
|
38
|
+
return i
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
i = File.join("#{@base_directory}", f_trimmed)
|
|
42
|
+
return i
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def route(request)
|
|
50
|
+
valid_path = search_path(request.path)
|
|
51
|
+
|
|
52
|
+
if valid_path != nil then
|
|
53
|
+
if valid_path.end_with?(".erb") and not @erb_enabled then
|
|
54
|
+
# Filter a file ending in .erb if .erb is not enabled. If the path ends with this return Not Found.
|
|
55
|
+
return Koi::Gemini::Response.new(Koi::Gemini::Status::NotFound, "Resource Not Found", nil)
|
|
56
|
+
elsif valid_path.split("/")[-1].start_with?(".") then
|
|
57
|
+
# Check for files beginning with . and ignore them.
|
|
58
|
+
|
|
59
|
+
# If the file begins with . as in .env then return Not Found.
|
|
60
|
+
return Koi::Gemini::Response.new(Koi::Gemini::Status::NotFound, "Resource Not Found", nil)
|
|
61
|
+
else
|
|
62
|
+
# Consider the route safe, figure out how to pack the response.
|
|
63
|
+
if valid_path.end_with?(".erb")
|
|
64
|
+
response = @erb_handler.handle(valid_path, request)
|
|
65
|
+
return response
|
|
66
|
+
else
|
|
67
|
+
status = Koi::Gemini::Status::Success
|
|
68
|
+
metadata = if valid_path.end_with?(".gmi")
|
|
69
|
+
"text/gemini; charset=utf-8"
|
|
70
|
+
elsif valid_path.end_with?(".txt")
|
|
71
|
+
"text/plain; charset=utf-8"
|
|
72
|
+
elsif valid_path.end_with?(".zip")
|
|
73
|
+
"application/zip"
|
|
74
|
+
elsif valid_path.end_with?(".pdf")
|
|
75
|
+
"application/pdf"
|
|
76
|
+
elsif valid_path.end_with?(".gif")
|
|
77
|
+
"image/gif"
|
|
78
|
+
elsif valid_path.end_with?(".jpeg", ".jpg")
|
|
79
|
+
"image/jpeg"
|
|
80
|
+
elsif valid_path.end_with?(".png")
|
|
81
|
+
"image/png"
|
|
82
|
+
else
|
|
83
|
+
"application/octet-stream"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
content = File.binread(valid_path)
|
|
87
|
+
return response = Koi::Gemini::Response.new(status, metadata, content)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
# No File Found
|
|
92
|
+
return Koi::Gemini::Response.new(Koi::Gemini::Status::NotFound, "Resource Not Found", nil)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
class Koi::Gemini::Request
|
|
2
|
+
@is_valid = true
|
|
3
|
+
|
|
4
|
+
attr_accessor :is_valid, :protocol, :domain, :path, :query
|
|
5
|
+
|
|
6
|
+
def self.parse(input)
|
|
7
|
+
if self.is_a?(Class)
|
|
8
|
+
req = Koi::Gemini::Request.allocate
|
|
9
|
+
|
|
10
|
+
if input[0...9] == "gemini://" then
|
|
11
|
+
req.protocol = "gemini://"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
domain_path_query = input[9..-1].gsub('\r\n','').strip
|
|
15
|
+
split = domain_path_query.split("/",2)
|
|
16
|
+
|
|
17
|
+
if split[0] != nil
|
|
18
|
+
and not split[0].include?("/")
|
|
19
|
+
and not split[0].include?('"')
|
|
20
|
+
and not split[0].include?("'")
|
|
21
|
+
and not split[0].include?(" ") then
|
|
22
|
+
|
|
23
|
+
req.domain = split[0]
|
|
24
|
+
req.path = "/index"
|
|
25
|
+
req.query = ""
|
|
26
|
+
req.is_valid = true
|
|
27
|
+
else
|
|
28
|
+
req.is_valid = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if split[1] != nil
|
|
32
|
+
if not split[1].include?("..")
|
|
33
|
+
and not split[1].include?(" ")
|
|
34
|
+
and not split[1].include?("'")
|
|
35
|
+
and not split[1].include?('"') then
|
|
36
|
+
if split[1].include?("?") then
|
|
37
|
+
path_query = split[1].split("?")
|
|
38
|
+
req.path = "/"+path_query[0]
|
|
39
|
+
req.query = path_query[1]
|
|
40
|
+
else
|
|
41
|
+
req.path = "/"+split[1]
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
req.is_valid = false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return req
|
|
49
|
+
end
|
|
50
|
+
raise "This method should be called from the class, not an instance!"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def is_valid?
|
|
54
|
+
if self.is_a?(Class)
|
|
55
|
+
raise "This method must be called on an instance, not the class!"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return @is_valid
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative './status'
|
|
2
|
+
|
|
3
|
+
class Koi::Gemini::Response
|
|
4
|
+
attr_accessor :status, :metadata, :content
|
|
5
|
+
|
|
6
|
+
def initialize(status, metadata, content)
|
|
7
|
+
@status = status
|
|
8
|
+
@metadata = metadata
|
|
9
|
+
@content = content
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
return "#{@status} #{metadata}\r\n#{@content}".b
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Koi::Gemini::Status
|
|
2
|
+
#1X
|
|
3
|
+
InputExpected = 10
|
|
4
|
+
InputExpectedSensitive = 11
|
|
5
|
+
|
|
6
|
+
#2X
|
|
7
|
+
Success = 20
|
|
8
|
+
|
|
9
|
+
#3X
|
|
10
|
+
TemporaryRedirection = 30
|
|
11
|
+
PermanentRedirection = 31
|
|
12
|
+
|
|
13
|
+
#4X
|
|
14
|
+
RetryableGenericError = 40
|
|
15
|
+
RetryableServerUnavailableError = 41
|
|
16
|
+
RetryableCGIError = 42
|
|
17
|
+
RetryableProxyError = 43
|
|
18
|
+
RetryableSlowDownError = 44
|
|
19
|
+
|
|
20
|
+
#5X
|
|
21
|
+
PermanentGenericError = 50
|
|
22
|
+
NotFound = 51
|
|
23
|
+
Gone = 52
|
|
24
|
+
ProxyRequestRefused = 53
|
|
25
|
+
BadRequest = 59
|
|
26
|
+
|
|
27
|
+
#60
|
|
28
|
+
NeedClientCertificate = 60
|
|
29
|
+
CertificateNotAuthorized = 61
|
|
30
|
+
CertificateNotValid = 62
|
|
31
|
+
end
|
data/lib/koi/gemini.rb
ADDED
data/lib/koi/server.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require_relative './gemini'
|
|
4
|
+
require_relative './ssl'
|
|
5
|
+
require_relative './file'
|
|
6
|
+
|
|
7
|
+
class Koi::Server
|
|
8
|
+
@config = {}
|
|
9
|
+
@file_server = nil
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@file_server = FileServer.new(@config[:file_root_dir], @config[:erb_enabled])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
# Spawn TCP listener.
|
|
18
|
+
server = TCPServer.new(@config[:bind_addr], @config[:bind_port])
|
|
19
|
+
|
|
20
|
+
puts "
|
|
21
|
+
|
|
22
|
+
:.
|
|
23
|
+
:xx
|
|
24
|
+
.;+x +;+:
|
|
25
|
+
.x +&;;;;;;;+;;;+;:;;X :.:.
|
|
26
|
+
::::+&&;::::::::::::::$+ ..:..;
|
|
27
|
+
;xx::::::::::;;::::::...::+ :;:.;;
|
|
28
|
+
;;::::::::;+xxxxx;::.:.. x+;+Xxx
|
|
29
|
+
+$::..:; .:: &$$$X;x
|
|
30
|
+
++;: &&&$$x
|
|
31
|
+
:x: +. &$$$x
|
|
32
|
+
+ ;;. $$XX
|
|
33
|
+
;; XXx$$&X;;&+X$& ;: :X&$
|
|
34
|
+
++; ++x::::::::x+&&&$$$Xxxx::::::;
|
|
35
|
+
+;+;; ++;:X:::::;$:;:XXXx:::xxX+xxxx;
|
|
36
|
+
:;;;:;. .::+X$;:$$$X+::::::x
|
|
37
|
+
:;;:;;: :. x.+
|
|
38
|
+
.:..:. . X:;
|
|
39
|
+
. . +++
|
|
40
|
+
::
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
"
|
|
44
|
+
puts "Koi Gemini Server - #{Koi::VERSION} - Listening on #{@config[:bind_addr]}:#{@config[:bind_port]}."
|
|
45
|
+
|
|
46
|
+
# Define SSL context
|
|
47
|
+
|
|
48
|
+
ctx = if @config[:key_file_path] == nil or
|
|
49
|
+
@config[:cert_file_path] == nil then
|
|
50
|
+
Koi::SSL.generate_debug_ctx
|
|
51
|
+
else
|
|
52
|
+
Koi::SSL.generate_ctx_from_files(@config[:key_file_path], @config[:cert_file_path])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Loop TCP connections with Thread pool.
|
|
56
|
+
loop do
|
|
57
|
+
Thread.start(server.accept) do |socket|
|
|
58
|
+
begin
|
|
59
|
+
# Begin client upgrade
|
|
60
|
+
client = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
|
61
|
+
client.accept
|
|
62
|
+
|
|
63
|
+
# Client stable begin server request handling
|
|
64
|
+
handle(client)
|
|
65
|
+
|
|
66
|
+
rescue OpenSSL::SSL::SSLError
|
|
67
|
+
puts "Failed to upgrade client connection to SSL."
|
|
68
|
+
ensure
|
|
69
|
+
ssl_socket.close rescue nil
|
|
70
|
+
socket.close rescue nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def handle(client)
|
|
77
|
+
request = Koi::Gemini::Request.parse(client.gets)
|
|
78
|
+
|
|
79
|
+
if not request.is_valid? then
|
|
80
|
+
resp = Koi::Gemini::Response.new(Koi::Gemini::Status::BadRequest)
|
|
81
|
+
|
|
82
|
+
puts "#{resp.status}: #{request.protocol}#{request.domain}#{request.path}" + (request.query != "" ? "?#{request.query}" : "")
|
|
83
|
+
client.send(resp.to_s)
|
|
84
|
+
else
|
|
85
|
+
resp = @file_server.route(request)
|
|
86
|
+
|
|
87
|
+
puts "#{resp.status}: #{request.protocol}#{request.domain}#{request.path}" + (request.query != "" ? "?#{request.query}" : "")
|
|
88
|
+
client.write(resp.to_s)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/koi/ssl.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
|
|
3
|
+
class Koi::SSL
|
|
4
|
+
|
|
5
|
+
def self.generate_debug_ctx
|
|
6
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
7
|
+
|
|
8
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
9
|
+
ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
10
|
+
|
|
11
|
+
ctx.key = generate_private_key
|
|
12
|
+
ctx.cert = generate_and_sign_cert(ctx.key, "localhost")
|
|
13
|
+
|
|
14
|
+
return ctx
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.generate_ctx_from_files(key_path, cert_path)
|
|
18
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
19
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
20
|
+
ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
21
|
+
|
|
22
|
+
# Load private key.
|
|
23
|
+
key_data = File.read(key_path)
|
|
24
|
+
private_key = OpenSSL::PKey.read(key_data, nil)
|
|
25
|
+
|
|
26
|
+
# Load the X509 certificate
|
|
27
|
+
cert_data = File.read(cert_path)
|
|
28
|
+
cert = OpenSSL::X509::Certificate.new(cert_data)
|
|
29
|
+
|
|
30
|
+
# Verify that the private key matches the certificate
|
|
31
|
+
if cert.check_private_key(private_key)
|
|
32
|
+
raise "Error: Private key does not match the certificate!"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
ctx.key = private_key
|
|
36
|
+
ctx.cert = cert
|
|
37
|
+
return ctx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def self.generate_key_and_cert(cn)
|
|
42
|
+
key = generate_private_key
|
|
43
|
+
cert = generate_and_sign_cert(key, cn)
|
|
44
|
+
|
|
45
|
+
return key, cert
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.generate_private_key
|
|
49
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
50
|
+
return key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.generate_and_sign_cert(key, cn)
|
|
54
|
+
cert = OpenSSL::X509::Certificate.new
|
|
55
|
+
cert.version = 2
|
|
56
|
+
cert.serial = 1
|
|
57
|
+
cert.subject = OpenSSL::X509::Name.new([['CN', cn]])
|
|
58
|
+
cert.issuer = cert.subject
|
|
59
|
+
cert.public_key = key.public_key
|
|
60
|
+
cert.not_before = Time.now
|
|
61
|
+
cert.not_after = Time.now + (60 * 60 * 24 * 365)
|
|
62
|
+
|
|
63
|
+
cert.sign(key, OpenSSL::Digest::SHA256.new)
|
|
64
|
+
|
|
65
|
+
return cert
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/koi.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Koi
|
|
3
|
+
VERSION = "1.0.0"
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
require_relative './koi/server'
|
|
7
|
+
|
|
8
|
+
module Koi
|
|
9
|
+
class Pond
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
server = Koi::Server.new(@config)
|
|
16
|
+
server.run
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: koi-server
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Justin Woodring
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Koi is a ruby-based Gemini server that supports .erb templating, and static files, and handles TLS certs for you.
|
|
14
|
+
|
|
15
|
+
Koi is designed to make hosting Gemini CGI pages a joyful and
|
|
16
|
+
tranquil experience, uniting the conceptual beauty of a simple web
|
|
17
|
+
with developer happiness.
|
|
18
|
+
|
|
19
|
+
By using a combination of Ruby powered ERB templating and
|
|
20
|
+
traditional static file serving we distill a solution offering
|
|
21
|
+
the best of both worlds.
|
|
22
|
+
email: jwoodrg@gmail.com
|
|
23
|
+
executables:
|
|
24
|
+
- koi
|
|
25
|
+
extensions: []
|
|
26
|
+
extra_rdoc_files: []
|
|
27
|
+
files:
|
|
28
|
+
- ".gemspec"
|
|
29
|
+
- ".gitignore"
|
|
30
|
+
- Gemfile
|
|
31
|
+
- README.org
|
|
32
|
+
- bin/koi
|
|
33
|
+
- lib/koi.rb
|
|
34
|
+
- lib/koi/erb.rb
|
|
35
|
+
- lib/koi/erb/handler.rb
|
|
36
|
+
- lib/koi/file.rb
|
|
37
|
+
- lib/koi/gemini.rb
|
|
38
|
+
- lib/koi/gemini/request.rb
|
|
39
|
+
- lib/koi/gemini/response.rb
|
|
40
|
+
- lib/koi/gemini/status.rb
|
|
41
|
+
- lib/koi/server.rb
|
|
42
|
+
- lib/koi/ssl.rb
|
|
43
|
+
homepage: https://rubygems.org/gems/koi
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
source_code_uri: https://github.com/JustinWoodring/Koi
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 4.0.11
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: Koi is a ruby-based Gemini server that supports ERB templating.
|
|
65
|
+
test_files: []
|