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.
Files changed (51) 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 +12 -0
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +14 -7
  7. data/README.md +184 -8
  8. data/Rakefile +1 -7
  9. data/benchmarks/bm_http1_parser.rb +1 -1
  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_utils.rb +1 -1
  16. data/examples/hello.rb +5 -0
  17. data/examples/http_server.js +1 -1
  18. data/examples/http_server_graceful.rb +1 -1
  19. data/examples/https_server.rb +41 -18
  20. data/examples/rack_server_forked.rb +26 -0
  21. data/examples/rack_server_https_forked.rb +1 -1
  22. data/examples/websocket_demo.rb +1 -1
  23. data/lib/tipi/acme.rb +46 -39
  24. data/lib/tipi/cli.rb +79 -16
  25. data/lib/tipi/config_dsl.rb +13 -13
  26. data/lib/tipi/configuration.rb +2 -2
  27. data/lib/tipi/controller/bare_polyphony.rb +0 -0
  28. data/lib/tipi/controller/bare_stock.rb +10 -0
  29. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  30. data/lib/tipi/controller/web_polyphony.rb +351 -0
  31. data/lib/tipi/controller/web_stock.rb +631 -0
  32. data/lib/tipi/controller.rb +12 -0
  33. data/lib/tipi/digital_fabric/agent.rb +3 -3
  34. data/lib/tipi/digital_fabric/agent_proxy.rb +11 -5
  35. data/lib/tipi/digital_fabric/executive.rb +1 -1
  36. data/lib/tipi/digital_fabric/protocol.rb +1 -1
  37. data/lib/tipi/digital_fabric/service.rb +8 -8
  38. data/lib/tipi/handler.rb +2 -2
  39. data/lib/tipi/http1_adapter.rb +32 -27
  40. data/lib/tipi/http2_adapter.rb +10 -10
  41. data/lib/tipi/http2_stream.rb +14 -14
  42. data/lib/tipi/rack_adapter.rb +2 -2
  43. data/lib/tipi/response_extensions.rb +1 -1
  44. data/lib/tipi/supervisor.rb +75 -0
  45. data/lib/tipi/version.rb +1 -1
  46. data/lib/tipi/websocket.rb +3 -3
  47. data/lib/tipi.rb +4 -83
  48. data/test/coverage.rb +2 -2
  49. data/test/test_http_server.rb +14 -14
  50. data/tipi.gemspec +3 -2
  51. 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