tipi 0.43 → 0.45

Sign up to get free protection for your applications and to get access to all the features.
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