tipi 0.41 → 0.42
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
- data/.github/workflows/test.yml +4 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +11 -0
- data/Gemfile +5 -1
- data/Gemfile.lock +26 -12
- data/benchmarks/bm_http1_parser.rb +61 -0
- data/bin/benchmark +37 -0
- data/bin/h1pd +6 -0
- data/bin/tipi +3 -21
- data/df/server.rb +1 -1
- data/df/server_utils.rb +49 -44
- data/examples/full_service.rb +13 -0
- data/examples/http1_parser.rb +10 -8
- data/examples/http_server.rb +4 -1
- data/examples/https_server.rb +3 -0
- data/examples/servername_cb.rb +37 -0
- data/ext/tipi/extconf.rb +3 -2
- data/ext/tipi/http1_parser.c +478 -189
- data/lib/tipi.rb +84 -3
- data/lib/tipi/acme.rb +308 -0
- data/lib/tipi/cli.rb +30 -0
- data/lib/tipi/digital_fabric/agent.rb +2 -2
- data/lib/tipi/digital_fabric/agent_proxy.rb +4 -3
- data/lib/tipi/digital_fabric/executive.rb +6 -2
- data/lib/tipi/digital_fabric/protocol.rb +2 -2
- data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
- data/lib/tipi/digital_fabric/service.rb +5 -10
- data/lib/tipi/http1_adapter.rb +55 -100
- data/lib/tipi/http2_adapter.rb +19 -6
- data/lib/tipi/http2_stream.rb +39 -43
- data/lib/tipi/version.rb +1 -1
- data/security/http1.rb +12 -0
- data/test/helper.rb +60 -11
- data/test/test_http1_parser.rb +586 -0
- data/test/test_http_server.rb +0 -27
- data/test/test_request.rb +1 -28
- data/tipi.gemspec +6 -5
- metadata +48 -27
- data/examples/automatic_certificate.rb +0 -193
- data/lib/tipi/http1_adapter_new.rb +0 -293
data/lib/tipi.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'polyphony'
|
4
|
+
|
4
5
|
require_relative './tipi/http1_adapter'
|
5
|
-
# require_relative './tipi/http1_adapter_new'
|
6
6
|
require_relative './tipi/http2_adapter'
|
7
7
|
require_relative './tipi/configuration'
|
8
8
|
require_relative './tipi/response_extensions'
|
9
|
+
require_relative './tipi/acme'
|
10
|
+
|
9
11
|
require 'qeweney/request'
|
10
12
|
|
11
13
|
class Qeweney::Request
|
@@ -49,16 +51,95 @@ module Tipi
|
|
49
51
|
ensure
|
50
52
|
client.close rescue nil
|
51
53
|
end
|
52
|
-
|
54
|
+
|
53
55
|
def protocol_adapter(socket, opts)
|
54
56
|
use_http2 = socket.respond_to?(:alpn_protocol) &&
|
55
57
|
socket.alpn_protocol == H2_PROTOCOL
|
56
|
-
klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
|
58
|
+
klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
|
57
59
|
klass.new(socket, opts)
|
58
60
|
end
|
59
61
|
|
60
62
|
def route(&block)
|
61
63
|
proc { |req| req.route(&block) }
|
62
64
|
end
|
65
|
+
|
66
|
+
CERTIFICATE_STORE_DEFAULT_DIR = File.expand_path('~/.tipi')
|
67
|
+
CERTIFICATE_STORE_DEFAULT_DB_PATH = File.join(
|
68
|
+
CERTIFICATE_STORE_DEFAULT_DIR, 'certificates.db'
|
69
|
+
)
|
70
|
+
|
71
|
+
def default_certificate_store
|
72
|
+
FileUtils.mkdir(CERTIFICATE_STORE_DEFAULT_DIR) rescue nil
|
73
|
+
Tipi::ACME::SQLiteCertificateStore.new(CERTIFICATE_STORE_DEFAULT_DB_PATH)
|
74
|
+
end
|
75
|
+
|
76
|
+
def full_service(
|
77
|
+
http_port: 10080,
|
78
|
+
https_port: 10443,
|
79
|
+
certificate_store: default_certificate_store,
|
80
|
+
app: nil, &block
|
81
|
+
)
|
82
|
+
app ||= block
|
83
|
+
raise "No app given" unless app
|
84
|
+
|
85
|
+
http_handler = ->(r) { r.redirect("https://#{r.host}#{r.path}") }
|
86
|
+
|
87
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
88
|
+
# ctx.ciphers = 'ECDH+aRSA'
|
89
|
+
Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS)
|
90
|
+
|
91
|
+
challenge_handler = Tipi::ACME::HTTPChallengeHandler.new
|
92
|
+
certificate_manager = Tipi::ACME::CertificateManager.new(
|
93
|
+
master_ctx: ctx,
|
94
|
+
store: certificate_store,
|
95
|
+
challenge_handler: challenge_handler
|
96
|
+
)
|
97
|
+
|
98
|
+
http_listener = spin do
|
99
|
+
opts = {
|
100
|
+
reuse_addr: true,
|
101
|
+
reuse_port: true,
|
102
|
+
dont_linger: true,
|
103
|
+
}
|
104
|
+
puts "Listening for HTTP on localhost:#{http_port}"
|
105
|
+
server = Polyphony::Net.tcp_listen('0.0.0.0', http_port, opts)
|
106
|
+
wrapped_handler = certificate_manager.challenge_routing_app(http_handler)
|
107
|
+
server.accept_loop do |client|
|
108
|
+
spin do
|
109
|
+
Tipi.client_loop(client, opts, &wrapped_handler)
|
110
|
+
rescue => e
|
111
|
+
puts "Uncaught error in HTTP listener: #{e.inspect}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
ensure
|
115
|
+
server.close
|
116
|
+
end
|
117
|
+
|
118
|
+
https_listener = spin do
|
119
|
+
opts = {
|
120
|
+
reuse_addr: true,
|
121
|
+
reuse_port: true,
|
122
|
+
dont_linger: true,
|
123
|
+
secure_context: ctx,
|
124
|
+
}
|
125
|
+
|
126
|
+
puts "Listening for HTTPS on localhost:#{https_port}"
|
127
|
+
server = Polyphony::Net.tcp_listen('0.0.0.0', https_port, opts)
|
128
|
+
loop do
|
129
|
+
client = server.accept
|
130
|
+
spin do
|
131
|
+
Tipi.client_loop(client, opts, &app)
|
132
|
+
rescue => e
|
133
|
+
puts "Uncaught error in HTTPS listener: #{e.inspect}"
|
134
|
+
end
|
135
|
+
rescue OpenSSL::SSL::SSLError, SystemCallError, TypeError
|
136
|
+
# ignore
|
137
|
+
end
|
138
|
+
ensure
|
139
|
+
server.close
|
140
|
+
end
|
141
|
+
|
142
|
+
Fiber.await(http_listener, https_listener)
|
143
|
+
end
|
63
144
|
end
|
64
145
|
end
|
data/lib/tipi/acme.rb
ADDED
@@ -0,0 +1,308 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require 'acme-client'
|
5
|
+
require 'localhost/authority'
|
6
|
+
|
7
|
+
module Tipi
|
8
|
+
module ACME
|
9
|
+
class Error < StandardError
|
10
|
+
end
|
11
|
+
|
12
|
+
class CertificateManager
|
13
|
+
def initialize(master_ctx:, store:, challenge_handler:)
|
14
|
+
@master_ctx = master_ctx
|
15
|
+
@store = store
|
16
|
+
@challenge_handler = challenge_handler
|
17
|
+
@contexts = {}
|
18
|
+
@requests = Polyphony::Queue.new
|
19
|
+
@worker = spin { run }
|
20
|
+
setup_sni_callback
|
21
|
+
end
|
22
|
+
|
23
|
+
ACME_CHALLENGE_PATH_REGEXP = /\/\.well\-known\/acme\-challenge/.freeze
|
24
|
+
|
25
|
+
def challenge_routing_app(app)
|
26
|
+
->(req) do
|
27
|
+
(req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app)
|
28
|
+
.(req)
|
29
|
+
rescue => e
|
30
|
+
puts "Error while handling request: #{e.inspect} (headers: #{req.headers})"
|
31
|
+
req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/
|
36
|
+
|
37
|
+
def setup_sni_callback
|
38
|
+
@master_ctx.servername_cb = proc do |_socket, name|
|
39
|
+
p servername_cb: name
|
40
|
+
state = { ctx: nil }
|
41
|
+
|
42
|
+
if name =~ IP_REGEXP
|
43
|
+
@master_ctx
|
44
|
+
else
|
45
|
+
@requests << [name, state]
|
46
|
+
wait_for_ctx(state)
|
47
|
+
p name: name, error: state if state[:error]
|
48
|
+
# Eventually we might want to return an error returned in
|
49
|
+
# state[:error]. For the time being we handle errors by returning the
|
50
|
+
# master context
|
51
|
+
state[:ctx] || @master_ctx
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def wait_for_ctx(state)
|
57
|
+
period = 0.00001
|
58
|
+
while !state[:ctx] && !state[:error]
|
59
|
+
orig_sleep period
|
60
|
+
period *= 2 if period < 0.1
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def run
|
65
|
+
loop do
|
66
|
+
name, state = @requests.shift
|
67
|
+
state[:ctx] = get_context(name)
|
68
|
+
rescue => e
|
69
|
+
state[:error] = e if state
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
LOCALHOST_REGEXP = /\.?localhost$/.freeze
|
74
|
+
|
75
|
+
def get_context(name)
|
76
|
+
@contexts[name] = setup_context(name)
|
77
|
+
end
|
78
|
+
|
79
|
+
def setup_context(name)
|
80
|
+
ctx = provision_context(name)
|
81
|
+
transfer_ctx_settings(ctx)
|
82
|
+
ctx
|
83
|
+
end
|
84
|
+
|
85
|
+
def provision_context(name)
|
86
|
+
return localhost_context if name =~ LOCALHOST_REGEXP
|
87
|
+
|
88
|
+
info = get_certificate(name)
|
89
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
90
|
+
chain = parse_certificate(info[:certificate])
|
91
|
+
cert = chain.shift
|
92
|
+
ctx.add_certificate(cert, info[:private_key], chain)
|
93
|
+
ctx
|
94
|
+
end
|
95
|
+
|
96
|
+
def transfer_ctx_settings(ctx)
|
97
|
+
ctx.alpn_protocols = @master_ctx.alpn_protocols
|
98
|
+
ctx.alpn_select_cb = @master_ctx.alpn_select_cb
|
99
|
+
ctx.ciphers = @master_ctx.ciphers
|
100
|
+
end
|
101
|
+
|
102
|
+
CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
|
103
|
+
|
104
|
+
def parse_certificate(certificate)
|
105
|
+
certificate
|
106
|
+
.scan(CERTIFICATE_REGEXP)
|
107
|
+
.map { |p| OpenSSL::X509::Certificate.new(p.first) }
|
108
|
+
end
|
109
|
+
|
110
|
+
def get_expired_stamp(certificate)
|
111
|
+
chain = parse_certificate(certificate)
|
112
|
+
cert = chain.shift
|
113
|
+
cert.not_after
|
114
|
+
end
|
115
|
+
|
116
|
+
def get_certificate(name)
|
117
|
+
entry = @store.get(name)
|
118
|
+
return entry if entry
|
119
|
+
|
120
|
+
provision_certificate(name).tap do |entry|
|
121
|
+
@store.set(name, **entry)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def localhost_context
|
126
|
+
@localhost_authority ||= Localhost::Authority.fetch
|
127
|
+
@localhost_authority.server_context
|
128
|
+
end
|
129
|
+
|
130
|
+
def private_key
|
131
|
+
@private_key ||= OpenSSL::PKey::RSA.new(4096)
|
132
|
+
end
|
133
|
+
|
134
|
+
ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
|
135
|
+
|
136
|
+
def acme_client
|
137
|
+
@acme_client ||= setup_acme_client
|
138
|
+
end
|
139
|
+
|
140
|
+
def setup_acme_client
|
141
|
+
client = Acme::Client.new(
|
142
|
+
private_key: private_key,
|
143
|
+
directory: ACME_DIRECTORY
|
144
|
+
)
|
145
|
+
account = client.new_account(
|
146
|
+
contact: 'mailto:info@noteflakes.com',
|
147
|
+
terms_of_service_agreed: true
|
148
|
+
)
|
149
|
+
client
|
150
|
+
end
|
151
|
+
|
152
|
+
def provision_certificate(name)
|
153
|
+
p provision_certificate: name
|
154
|
+
order = acme_client.new_order(identifiers: [name])
|
155
|
+
authorization = order.authorizations.first
|
156
|
+
challenge = authorization.http
|
157
|
+
|
158
|
+
@challenge_handler.add(challenge)
|
159
|
+
challenge.request_validation
|
160
|
+
while challenge.status == 'pending'
|
161
|
+
sleep(0.25)
|
162
|
+
challenge.reload
|
163
|
+
end
|
164
|
+
raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
|
165
|
+
|
166
|
+
p challenge_status: challenge.status
|
167
|
+
private_key = OpenSSL::PKey::RSA.new(4096)
|
168
|
+
csr = Acme::Client::CertificateRequest.new(
|
169
|
+
private_key: private_key,
|
170
|
+
subject: { common_name: name }
|
171
|
+
)
|
172
|
+
order.finalize(csr: csr)
|
173
|
+
while order.status == 'processing'
|
174
|
+
sleep(0.25)
|
175
|
+
order.reload
|
176
|
+
end
|
177
|
+
certificate = begin
|
178
|
+
order.certificate(force_chain: 'DST Root CA X3')
|
179
|
+
rescue Acme::Client::Error::ForcedChainNotFound
|
180
|
+
order.certificate
|
181
|
+
end
|
182
|
+
expired_stamp = get_expired_stamp(certificate)
|
183
|
+
puts "Certificate for #{name} expires: #{expired_stamp.inspect}"
|
184
|
+
|
185
|
+
{
|
186
|
+
private_key: private_key,
|
187
|
+
certificate: certificate,
|
188
|
+
expired_stamp: expired_stamp
|
189
|
+
}
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
class HTTPChallengeHandler
|
194
|
+
def initialize
|
195
|
+
@challenges = {}
|
196
|
+
end
|
197
|
+
|
198
|
+
def add(challenge)
|
199
|
+
path = "/.well-known/acme-challenge/#{challenge.token}"
|
200
|
+
@challenges[path] = challenge
|
201
|
+
end
|
202
|
+
|
203
|
+
def remove(challenge)
|
204
|
+
path = "/.well-known/acme-challenge/#{challenge.token}"
|
205
|
+
@challenges.delete(path)
|
206
|
+
end
|
207
|
+
|
208
|
+
def call(req)
|
209
|
+
challenge = @challenges[req.path]
|
210
|
+
|
211
|
+
# handle incoming request
|
212
|
+
challenge = @challenges[req.path]
|
213
|
+
return req.respond(nil, ':status' => 400) unless challenge
|
214
|
+
|
215
|
+
req.respond(challenge.file_content, 'content-type' => challenge.content_type)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
class CertificateStore
|
220
|
+
def set(name, private_key:, certificate:, expired_stamp:)
|
221
|
+
raise NotImplementedError
|
222
|
+
end
|
223
|
+
|
224
|
+
def get(name)
|
225
|
+
raise NotImplementedError
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
class InMemoryCertificateStore
|
230
|
+
def initialize
|
231
|
+
@store = {}
|
232
|
+
end
|
233
|
+
|
234
|
+
def set(name, private_key:, certificate:, expired_stamp:)
|
235
|
+
@store[name] = {
|
236
|
+
private_key: private_key,
|
237
|
+
certificate: certificate,
|
238
|
+
expired_stamp: expired_stamp
|
239
|
+
}
|
240
|
+
end
|
241
|
+
|
242
|
+
def get(name)
|
243
|
+
entry = @store[name]
|
244
|
+
return nil unless entry
|
245
|
+
if Time.now >= entry[:expired_stamp]
|
246
|
+
@store.delete(name)
|
247
|
+
return nil
|
248
|
+
end
|
249
|
+
|
250
|
+
entry
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
class SQLiteCertificateStore
|
255
|
+
attr_reader :db
|
256
|
+
|
257
|
+
def initialize(path)
|
258
|
+
require 'extralite'
|
259
|
+
|
260
|
+
@db = Extralite::Database.new(path)
|
261
|
+
@db.query("
|
262
|
+
create table if not exists certificates (
|
263
|
+
name primary key not null,
|
264
|
+
private_key not null,
|
265
|
+
certificate not null,
|
266
|
+
expired_stamp not null
|
267
|
+
);"
|
268
|
+
)
|
269
|
+
end
|
270
|
+
|
271
|
+
def set(name, private_key:, certificate:, expired_stamp:)
|
272
|
+
@db.query("
|
273
|
+
insert into certificates values (?, ?, ?, ?)
|
274
|
+
", name, private_key.to_s, certificate, expired_stamp.to_i)
|
275
|
+
rescue Extralite::Error => e
|
276
|
+
p error_in_set: e
|
277
|
+
raise e
|
278
|
+
end
|
279
|
+
|
280
|
+
def get(name)
|
281
|
+
remove_expired_certificates
|
282
|
+
|
283
|
+
entry = @db.query_single_row("
|
284
|
+
select name, private_key, certificate, expired_stamp
|
285
|
+
from certificates
|
286
|
+
where name = ?
|
287
|
+
", name)
|
288
|
+
return nil unless entry
|
289
|
+
entry[:expired_stamp] = Time.at(entry[:expired_stamp])
|
290
|
+
entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key])
|
291
|
+
entry
|
292
|
+
rescue Extralite::Error => e
|
293
|
+
p error_in_get: e
|
294
|
+
raise e
|
295
|
+
end
|
296
|
+
|
297
|
+
def remove_expired_certificates
|
298
|
+
@db.query("
|
299
|
+
delete from certificates
|
300
|
+
where expired_stamp < ?
|
301
|
+
", Time.now.to_i)
|
302
|
+
rescue Extralite::Error => e
|
303
|
+
p error_in_remove_expired_certificates: e
|
304
|
+
raise e
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
data/lib/tipi/cli.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tipi'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
module Tipi
|
7
|
+
module CLI
|
8
|
+
BANNER = "
|
9
|
+
ooo
|
10
|
+
oo
|
11
|
+
o
|
12
|
+
\\|/ Tipi - a better web server for a better world
|
13
|
+
/ \\
|
14
|
+
/ \\ https://github.com/digital-fabric/tipi
|
15
|
+
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
|
16
|
+
"
|
17
|
+
|
18
|
+
def self.start
|
19
|
+
display_banner
|
20
|
+
require File.expand_path(ARGV[0] || 'app.rb', FileUtils.pwd)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.display_banner
|
24
|
+
puts BANNER
|
25
|
+
puts
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
__END__
|