tipi 0.41 → 0.46

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/test.yml +3 -1
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +34 -0
  6. data/Gemfile +7 -1
  7. data/Gemfile.lock +53 -33
  8. data/README.md +184 -8
  9. data/Rakefile +1 -7
  10. data/benchmarks/bm_http1_parser.rb +85 -0
  11. data/bin/benchmark +37 -0
  12. data/bin/h1pd +6 -0
  13. data/bin/tipi +3 -21
  14. data/bm.png +0 -0
  15. data/df/agent.rb +1 -1
  16. data/df/sample_agent.rb +2 -2
  17. data/df/server.rb +3 -1
  18. data/df/server_utils.rb +48 -46
  19. data/examples/full_service.rb +13 -0
  20. data/examples/hello.rb +5 -0
  21. data/examples/hello.ru +3 -3
  22. data/examples/http1_parser.rb +10 -8
  23. data/examples/http_server.js +1 -1
  24. data/examples/http_server.rb +4 -1
  25. data/examples/http_server_graceful.rb +1 -1
  26. data/examples/https_server.rb +41 -15
  27. data/examples/rack_server_forked.rb +26 -0
  28. data/examples/rack_server_https_forked.rb +1 -1
  29. data/examples/servername_cb.rb +37 -0
  30. data/examples/websocket_demo.rb +1 -1
  31. data/lib/tipi/acme.rb +320 -0
  32. data/lib/tipi/cli.rb +93 -0
  33. data/lib/tipi/config_dsl.rb +13 -13
  34. data/lib/tipi/configuration.rb +2 -2
  35. data/lib/tipi/controller/bare_polyphony.rb +0 -0
  36. data/lib/tipi/controller/bare_stock.rb +10 -0
  37. data/lib/tipi/controller/extensions.rb +37 -0
  38. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  39. data/lib/tipi/controller/web_polyphony.rb +353 -0
  40. data/lib/tipi/controller/web_stock.rb +635 -0
  41. data/lib/tipi/controller.rb +12 -0
  42. data/lib/tipi/digital_fabric/agent.rb +5 -5
  43. data/lib/tipi/digital_fabric/agent_proxy.rb +15 -8
  44. data/lib/tipi/digital_fabric/executive.rb +7 -3
  45. data/lib/tipi/digital_fabric/protocol.rb +3 -3
  46. data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
  47. data/lib/tipi/digital_fabric/service.rb +17 -18
  48. data/lib/tipi/handler.rb +2 -2
  49. data/lib/tipi/http1_adapter.rb +85 -124
  50. data/lib/tipi/http2_adapter.rb +29 -16
  51. data/lib/tipi/http2_stream.rb +52 -57
  52. data/lib/tipi/rack_adapter.rb +2 -2
  53. data/lib/tipi/response_extensions.rb +1 -1
  54. data/lib/tipi/supervisor.rb +75 -0
  55. data/lib/tipi/version.rb +1 -1
  56. data/lib/tipi/websocket.rb +3 -3
  57. data/lib/tipi.rb +9 -7
  58. data/test/coverage.rb +2 -2
  59. data/test/helper.rb +60 -12
  60. data/test/test_http_server.rb +14 -41
  61. data/test/test_request.rb +2 -29
  62. data/tipi.gemspec +10 -10
  63. metadata +80 -54
  64. data/examples/automatic_certificate.rb +0 -193
  65. data/ext/tipi/extconf.rb +0 -12
  66. data/ext/tipi/http1_parser.c +0 -534
  67. data/ext/tipi/http1_parser.h +0 -18
  68. data/ext/tipi/tipi_ext.c +0 -5
  69. data/lib/tipi/http1_adapter_new.rb +0 -293
@@ -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