tipi 0.43 → 0.45
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/FUNDING.yml +1 -0
- data/.github/workflows/test.yml +1 -3
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -1
- data/Gemfile.lock +14 -7
- data/README.md +184 -8
- data/Rakefile +1 -7
- data/benchmarks/bm_http1_parser.rb +1 -1
- data/bin/benchmark +0 -0
- data/bin/h1pd +0 -0
- data/bm.png +0 -0
- data/df/agent.rb +1 -1
- data/df/sample_agent.rb +2 -2
- data/df/server_utils.rb +1 -1
- data/examples/hello.rb +5 -0
- data/examples/http_server.js +1 -1
- data/examples/http_server_graceful.rb +1 -1
- data/examples/https_server.rb +41 -18
- data/examples/rack_server_forked.rb +26 -0
- data/examples/rack_server_https_forked.rb +1 -1
- data/examples/websocket_demo.rb +1 -1
- data/lib/tipi/acme.rb +46 -39
- data/lib/tipi/cli.rb +79 -16
- data/lib/tipi/config_dsl.rb +13 -13
- data/lib/tipi/configuration.rb +2 -2
- data/lib/tipi/controller/bare_polyphony.rb +0 -0
- data/lib/tipi/controller/bare_stock.rb +10 -0
- data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
- data/lib/tipi/controller/web_polyphony.rb +351 -0
- data/lib/tipi/controller/web_stock.rb +631 -0
- data/lib/tipi/controller.rb +12 -0
- data/lib/tipi/digital_fabric/agent.rb +3 -3
- data/lib/tipi/digital_fabric/agent_proxy.rb +11 -5
- data/lib/tipi/digital_fabric/executive.rb +1 -1
- data/lib/tipi/digital_fabric/protocol.rb +1 -1
- data/lib/tipi/digital_fabric/service.rb +8 -8
- data/lib/tipi/handler.rb +2 -2
- data/lib/tipi/http1_adapter.rb +32 -27
- data/lib/tipi/http2_adapter.rb +10 -10
- data/lib/tipi/http2_stream.rb +14 -14
- data/lib/tipi/rack_adapter.rb +2 -2
- data/lib/tipi/response_extensions.rb +1 -1
- data/lib/tipi/supervisor.rb +75 -0
- data/lib/tipi/version.rb +1 -1
- data/lib/tipi/websocket.rb +3 -3
- data/lib/tipi.rb +4 -83
- data/test/coverage.rb +2 -2
- data/test/test_http_server.rb +14 -14
- data/tipi.gemspec +3 -2
- metadata +30 -5
@@ -0,0 +1,351 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tipi'
|
4
|
+
require 'localhost/authority'
|
5
|
+
|
6
|
+
module Tipi
|
7
|
+
class Controller
|
8
|
+
def initialize(opts)
|
9
|
+
@opts = opts
|
10
|
+
@path = File.expand_path(@opts['path'])
|
11
|
+
@service = prepare_service
|
12
|
+
end
|
13
|
+
|
14
|
+
WORKER_COUNT_RANGE = (1..32).freeze
|
15
|
+
|
16
|
+
def run
|
17
|
+
worker_count = (@opts['workers'] || 1).to_i.clamp(WORKER_COUNT_RANGE)
|
18
|
+
return run_worker if worker_count == 1
|
19
|
+
|
20
|
+
supervise_workers(worker_count)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def supervise_workers(worker_count)
|
26
|
+
supervisor = spin do
|
27
|
+
worker_count.times do
|
28
|
+
spin do
|
29
|
+
pid = Polyphony.fork { run_worker }
|
30
|
+
puts "Forked worker pid: #{pid}"
|
31
|
+
Polyphony.backend_waitpid(pid)
|
32
|
+
puts "Done worker pid: #{pid}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
supervise(restart: :always)
|
36
|
+
rescue Polyphony::Terminate
|
37
|
+
# TODO: find out how Terminate can leak like that (it's supposed to be
|
38
|
+
# caught in Fiber#run)
|
39
|
+
end
|
40
|
+
trap('SIGTERM') { supervisor.terminate(true) }
|
41
|
+
trap('SIGINT') do
|
42
|
+
trap('SIGINT') { exit! }
|
43
|
+
supervisor.terminate(true)
|
44
|
+
end
|
45
|
+
|
46
|
+
supervisor.await
|
47
|
+
rescue Polyphony::Terminate
|
48
|
+
# TODO: find out how Terminate can leak etc.
|
49
|
+
end
|
50
|
+
|
51
|
+
def run_worker
|
52
|
+
server = start_server(@service)
|
53
|
+
trap('SIGTERM') { server&.terminate(true) }
|
54
|
+
trap('SIGINT') do
|
55
|
+
trap('SIGINT') { exit! }
|
56
|
+
server&.terminate(true)
|
57
|
+
end
|
58
|
+
raise 'Server not started' unless server
|
59
|
+
server.await
|
60
|
+
rescue Polyphony::Terminate
|
61
|
+
# TODO: find out why this exception leaks from the server fiber
|
62
|
+
# ignore
|
63
|
+
end
|
64
|
+
|
65
|
+
def prepare_service
|
66
|
+
if File.file?(@path)
|
67
|
+
File.extname(@path) == '.ru' ? rack_service : tipi_service
|
68
|
+
elsif File.directory?(@path)
|
69
|
+
static_service
|
70
|
+
else
|
71
|
+
raise "Invalid path specified #{@path}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def start_app
|
76
|
+
if File.extname(@path) == '.ru'
|
77
|
+
start_rack_app
|
78
|
+
else
|
79
|
+
require(@path)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def rack_service
|
84
|
+
puts "Loading Rack app from #{@path}"
|
85
|
+
app = Tipi::RackAdapter.load(@path)
|
86
|
+
web_service(app)
|
87
|
+
end
|
88
|
+
|
89
|
+
def tipi_service
|
90
|
+
puts "Loading Tipi app from #{@path}"
|
91
|
+
require(@path)
|
92
|
+
app = Object.send(:app)
|
93
|
+
web_service(app)
|
94
|
+
# proc { spin { Object.run } }
|
95
|
+
end
|
96
|
+
|
97
|
+
def static_service
|
98
|
+
puts "Serving static files from #{@path}"
|
99
|
+
app = proc do |req|
|
100
|
+
full_path = find_path(@path, req.path)
|
101
|
+
if full_path
|
102
|
+
req.serve_file(full_path)
|
103
|
+
else
|
104
|
+
req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
web_service(app)
|
108
|
+
end
|
109
|
+
|
110
|
+
def web_service(app)
|
111
|
+
app = add_connection_headers(app)
|
112
|
+
|
113
|
+
prepare_listener(@opts['listen'], app)
|
114
|
+
end
|
115
|
+
|
116
|
+
def prepare_listener(spec, app)
|
117
|
+
case spec.shift
|
118
|
+
when 'http'
|
119
|
+
case spec.size
|
120
|
+
when 2
|
121
|
+
host, port = spec
|
122
|
+
port ||= 80
|
123
|
+
when 1
|
124
|
+
host = '0.0.0.0'
|
125
|
+
port = spec.first || 80
|
126
|
+
else
|
127
|
+
raise "Invalid listener spec"
|
128
|
+
end
|
129
|
+
prepare_http_listener(port, app)
|
130
|
+
when 'https'
|
131
|
+
case spec.size
|
132
|
+
when 2
|
133
|
+
host, port = spec
|
134
|
+
port ||= 80
|
135
|
+
when 1
|
136
|
+
host = 'localhost'
|
137
|
+
port = spec.first || 80
|
138
|
+
else
|
139
|
+
raise "Invalid listener spec"
|
140
|
+
end
|
141
|
+
port ||= 443
|
142
|
+
prepare_https_listener(host, port, app)
|
143
|
+
when 'full'
|
144
|
+
host, http_port, https_port = spec
|
145
|
+
http_port ||= 80
|
146
|
+
https_port ||= 443
|
147
|
+
prepare_full_service_listeners(host, http_port, https_port, app)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def prepare_http_listener(port, app)
|
152
|
+
puts "Listening for HTTP on localhost:#{port}"
|
153
|
+
|
154
|
+
proc do
|
155
|
+
spin_accept_loop('HTTP', port) do |socket|
|
156
|
+
Tipi.client_loop(socket, @opts, &app)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
LOCALHOST_REGEXP = /^(.+\.)?localhost$/.freeze
|
162
|
+
|
163
|
+
def prepare_https_listener(host, port, app)
|
164
|
+
localhost = host =~ LOCALHOST_REGEXP
|
165
|
+
return prepare_localhost_https_listener(port, app) if localhost
|
166
|
+
|
167
|
+
raise "No certificate found for #{host}"
|
168
|
+
# TODO: implement loading certificate
|
169
|
+
end
|
170
|
+
|
171
|
+
def prepare_localhost_https_listener(port, app)
|
172
|
+
puts "Listening for HTTPS on localhost:#{port}"
|
173
|
+
|
174
|
+
authority = Localhost::Authority.fetch
|
175
|
+
ctx = authority.server_context
|
176
|
+
ctx.ciphers = 'ECDH+aRSA'
|
177
|
+
Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS)
|
178
|
+
|
179
|
+
proc do
|
180
|
+
https_listener = spin_accept_loop('HTTPS', port) do |socket|
|
181
|
+
start_https_connection_fiber(socket, ctx, nil, app)
|
182
|
+
rescue Exception => e
|
183
|
+
puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def prepare_full_service_listeners(host, http_port, https_port, app)
|
189
|
+
puts "Listening for HTTP on localhost:#{http_port}"
|
190
|
+
puts "Listening for HTTPS on localhost:#{https_port}"
|
191
|
+
|
192
|
+
redirect_app = http_redirect_app(https_port)
|
193
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
194
|
+
ctx.ciphers = 'ECDH+aRSA'
|
195
|
+
Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS)
|
196
|
+
certificate_store = create_certificate_store
|
197
|
+
|
198
|
+
proc do
|
199
|
+
challenge_handler = Tipi::ACME::HTTPChallengeHandler.new
|
200
|
+
certificate_manager = Tipi::ACME::CertificateManager.new(
|
201
|
+
master_ctx: ctx,
|
202
|
+
store: certificate_store,
|
203
|
+
challenge_handler: challenge_handler
|
204
|
+
)
|
205
|
+
http_app = certificate_manager.challenge_routing_app(redirect_app)
|
206
|
+
|
207
|
+
http_listener = spin_accept_loop('HTTP', http_port) do |socket|
|
208
|
+
Tipi.client_loop(socket, @opts, &http_app)
|
209
|
+
end
|
210
|
+
|
211
|
+
ssl_accept_thread_pool = Polyphony::ThreadPool.new(4)
|
212
|
+
|
213
|
+
https_listener = spin_accept_loop('HTTPS', https_port) do |socket|
|
214
|
+
start_https_connection_fiber(socket, ctx, ssl_accept_thread_pool, app)
|
215
|
+
rescue Exception => e
|
216
|
+
puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def http_redirect_app(https_port)
|
222
|
+
case https_port
|
223
|
+
when 443, 10443
|
224
|
+
->(req) { req.redirect("https://#{req.host}#{req.path}") }
|
225
|
+
else
|
226
|
+
->(req) { req.redirect("https://#{req.host}:#{https_port}#{req.path}") }
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
INVALID_PATH_REGEXP = /\/?(\.\.|\.)\//
|
231
|
+
|
232
|
+
def find_path(base, path)
|
233
|
+
return nil if path =~ INVALID_PATH_REGEXP
|
234
|
+
|
235
|
+
full_path = File.join(base, path)
|
236
|
+
return full_path if File.file?(full_path)
|
237
|
+
return find_path(full_path, 'index') if File.directory?(full_path)
|
238
|
+
|
239
|
+
qualified = "#{full_path}.html"
|
240
|
+
return qualified if File.file?(qualified)
|
241
|
+
|
242
|
+
nil
|
243
|
+
end
|
244
|
+
|
245
|
+
SOCKET_OPTS = {
|
246
|
+
reuse_addr: true,
|
247
|
+
reuse_port: true,
|
248
|
+
dont_linger: true,
|
249
|
+
}.freeze
|
250
|
+
|
251
|
+
def spin_accept_loop(name, port, &block)
|
252
|
+
spin do
|
253
|
+
server = Polyphony::Net.tcp_listen('0.0.0.0', port, SOCKET_OPTS)
|
254
|
+
loop do
|
255
|
+
socket = server.accept
|
256
|
+
spin_connection_handler(name, socket, block)
|
257
|
+
rescue Polyphony::BaseException => e
|
258
|
+
raise
|
259
|
+
rescue Exception => e
|
260
|
+
puts "#{name} listener uncaught exception: #{e.inspect}"
|
261
|
+
end
|
262
|
+
ensure
|
263
|
+
finalize_listener(server) if server
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
def spin_connection_handler(name, socket, block)
|
268
|
+
spin do
|
269
|
+
block.(socket)
|
270
|
+
rescue Polyphony::BaseException
|
271
|
+
raise
|
272
|
+
rescue Exception => e
|
273
|
+
puts "Uncaught error in #{name} handler: #{e.inspect}"
|
274
|
+
p e.backtrace
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
def finalize_listener(server)
|
279
|
+
fiber = Fiber.current
|
280
|
+
gracefully_terminate_conections(fiber) if fiber.graceful_shutdown?
|
281
|
+
server.close
|
282
|
+
rescue Polyphony::BaseException
|
283
|
+
raise
|
284
|
+
rescue Exception => e
|
285
|
+
trace "Exception in finalize_listener: #{e.inspect}"
|
286
|
+
end
|
287
|
+
|
288
|
+
def gracefully_terminate_conections(fiber)
|
289
|
+
supervisor = spin { supervise }.detach
|
290
|
+
fiber.attach_all_children_to(supervisor)
|
291
|
+
|
292
|
+
# terminating the supervisor will
|
293
|
+
supervisor.terminate(true)
|
294
|
+
end
|
295
|
+
|
296
|
+
def add_connection_headers(app)
|
297
|
+
app
|
298
|
+
# proc do |req|
|
299
|
+
# conn = req.adapter.conn
|
300
|
+
# # req.headers[':peer'] = conn.peeraddr(false)[2]
|
301
|
+
# req.headers[':scheme'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http'
|
302
|
+
# app.(req)
|
303
|
+
# end
|
304
|
+
end
|
305
|
+
|
306
|
+
def ssl_accept(client)
|
307
|
+
client.accept
|
308
|
+
true
|
309
|
+
rescue Polyphony::BaseException
|
310
|
+
raise
|
311
|
+
rescue Exception => e
|
312
|
+
p e
|
313
|
+
e
|
314
|
+
end
|
315
|
+
|
316
|
+
def start_https_connection_fiber(socket, ctx, thread_pool, app)
|
317
|
+
client = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
318
|
+
client.sync_close = true
|
319
|
+
|
320
|
+
result = thread_pool ?
|
321
|
+
thread_pool.process { ssl_accept(client) } : ssl_accept(client)
|
322
|
+
|
323
|
+
if result.is_a?(Exception)
|
324
|
+
puts "Exception in SSL handshake: #{result.inspect}"
|
325
|
+
return
|
326
|
+
end
|
327
|
+
|
328
|
+
Tipi.client_loop(client, @opts, &app)
|
329
|
+
rescue => e
|
330
|
+
puts "Uncaught error in HTTPS connection fiber: #{e.inspect} bt: #{e.backtrace.inspect}"
|
331
|
+
ensure
|
332
|
+
(client ? client.close : socket.close) rescue nil
|
333
|
+
end
|
334
|
+
|
335
|
+
CERTIFICATE_STORE_DEFAULT_DIR = File.expand_path('~/.tipi').freeze
|
336
|
+
CERTIFICATE_STORE_DEFAULT_DB_PATH = File.join(
|
337
|
+
CERTIFICATE_STORE_DEFAULT_DIR, 'certificates.db').freeze
|
338
|
+
|
339
|
+
def create_certificate_store
|
340
|
+
FileUtils.mkdir(CERTIFICATE_STORE_DEFAULT_DIR) rescue nil
|
341
|
+
Tipi::ACME::SQLiteCertificateStore.new(CERTIFICATE_STORE_DEFAULT_DB_PATH)
|
342
|
+
end
|
343
|
+
|
344
|
+
def start_server(service)
|
345
|
+
spin do
|
346
|
+
service.call
|
347
|
+
supervise(restart: :always)
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|