tipi 0.42 → 0.47

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