tipi 0.41 → 0.46

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 (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
data/lib/tipi/acme.rb ADDED
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'acme-client'
5
+ require 'localhost/authority'
6
+
7
+ module Tipi
8
+ module ACME
9
+ class Error < StandardError
10
+ end
11
+
12
+ class CertificateManager
13
+ def initialize(master_ctx:, store:, challenge_handler:, valid_hosts:)
14
+ @master_ctx = master_ctx
15
+ @store = store
16
+ @challenge_handler = challenge_handler
17
+ @valid_hosts = valid_hosts
18
+ @contexts = {}
19
+ @requests = Polyphony::Queue.new
20
+ @worker = spin { run }
21
+ setup_sni_callback
22
+ end
23
+
24
+ ACME_CHALLENGE_PATH_REGEXP = /\/\.well\-known\/acme\-challenge/.freeze
25
+
26
+ def challenge_routing_app(app)
27
+ ->(req) do
28
+ (req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app)
29
+ .(req)
30
+ rescue => e
31
+ puts "Error while handling request: #{e.inspect} (headers: #{req.headers})"
32
+ req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST)
33
+ end
34
+ end
35
+
36
+ IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/
37
+
38
+ def setup_sni_callback
39
+ @master_ctx.servername_cb = proc { |_socket, name| get_ctx(name) }
40
+ end
41
+
42
+ def get_ctx(name)
43
+ state = { ctx: nil }
44
+
45
+ if @valid_hosts
46
+ return nil unless @valid_hosts.include?(name)
47
+ end
48
+
49
+ ready_ctx = @contexts[name]
50
+ return ready_ctx if ready_ctx
51
+ return @master_ctx if name =~ IP_REGEXP
52
+
53
+ @requests << [name, state]
54
+ wait_for_ctx(state)
55
+ # Eventually we might want to return an error returned in
56
+ # state[:error]. For the time being we handle errors by returning the
57
+ # master context
58
+ state[:ctx] || @master_ctx
59
+ rescue => e
60
+ @master_ctx
61
+ end
62
+
63
+ MAX_WAIT_FOR_CTX_DURATION = 30
64
+
65
+ def wait_for_ctx(state)
66
+ t0 = Time.now
67
+ period = 0.00001
68
+ while !state[:ctx] && !state[:error]
69
+ orig_sleep period
70
+ if period < 0.1
71
+ period *= 2
72
+ elsif Time.now - t0 > MAX_WAIT_FOR_CTX_DURATION
73
+ raise "Timeout waiting for certificate provisioning"
74
+ end
75
+ end
76
+ end
77
+
78
+ def run
79
+ loop do
80
+ name, state = @requests.shift
81
+ state[:ctx] = get_context(name)
82
+ rescue => e
83
+ state[:error] = e if state
84
+ end
85
+ end
86
+
87
+ LOCALHOST_REGEXP = /\.?localhost$/.freeze
88
+
89
+ def get_context(name)
90
+ @contexts[name] = setup_context(name)
91
+ end
92
+
93
+ def setup_context(name)
94
+ ctx = provision_context(name)
95
+ transfer_ctx_settings(ctx)
96
+ ctx
97
+ end
98
+
99
+ def provision_context(name)
100
+ return localhost_context if name =~ LOCALHOST_REGEXP
101
+
102
+ info = get_certificate(name)
103
+ ctx = OpenSSL::SSL::SSLContext.new
104
+ chain = parse_certificate(info[:certificate])
105
+ cert = chain.shift
106
+ ctx.add_certificate(cert, info[:private_key], chain)
107
+ ctx
108
+ end
109
+
110
+ def transfer_ctx_settings(ctx)
111
+ ctx.alpn_protocols = @master_ctx.alpn_protocols
112
+ ctx.alpn_select_cb = @master_ctx.alpn_select_cb
113
+ ctx.ciphers = @master_ctx.ciphers
114
+ end
115
+
116
+ CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
117
+
118
+ def parse_certificate(certificate)
119
+ certificate
120
+ .scan(CERTIFICATE_REGEXP)
121
+ .map { |p| OpenSSL::X509::Certificate.new(p.first) }
122
+ end
123
+
124
+ def get_expired_stamp(certificate)
125
+ chain = parse_certificate(certificate)
126
+ cert = chain.shift
127
+ cert.not_after
128
+ end
129
+
130
+ def get_certificate(name)
131
+ entry = @store.get(name)
132
+ return entry if entry
133
+
134
+ provision_certificate(name).tap do |entry|
135
+ @store.set(name, **entry)
136
+ end
137
+ end
138
+
139
+ def localhost_context
140
+ @localhost_authority ||= Localhost::Authority.fetch
141
+ @localhost_authority.server_context
142
+ end
143
+
144
+ def private_key
145
+ @private_key ||= OpenSSL::PKey::RSA.new(4096)
146
+ end
147
+
148
+ ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
149
+
150
+ def acme_client
151
+ @acme_client ||= setup_acme_client
152
+ end
153
+
154
+ def setup_acme_client
155
+ client = Acme::Client.new(
156
+ private_key: private_key,
157
+ directory: ACME_DIRECTORY
158
+ )
159
+ account = client.new_account(
160
+ contact: 'mailto:info@noteflakes.com',
161
+ terms_of_service_agreed: true
162
+ )
163
+ client
164
+ end
165
+
166
+ def provision_certificate(name)
167
+ order = acme_client.new_order(identifiers: [name])
168
+ authorization = order.authorizations.first
169
+ challenge = authorization.http
170
+
171
+ @challenge_handler.add(challenge)
172
+ challenge.request_validation
173
+ while challenge.status == 'pending'
174
+ sleep(0.25)
175
+ challenge.reload
176
+ end
177
+ raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
178
+
179
+ private_key = OpenSSL::PKey::RSA.new(4096)
180
+ csr = Acme::Client::CertificateRequest.new(
181
+ private_key: private_key,
182
+ subject: { common_name: name }
183
+ )
184
+ order.finalize(csr: csr)
185
+ while order.status == 'processing'
186
+ sleep(0.25)
187
+ order.reload
188
+ end
189
+ certificate = begin
190
+ order.certificate(force_chain: 'DST Root CA X3')
191
+ rescue Acme::Client::Error::ForcedChainNotFound
192
+ order.certificate
193
+ end
194
+ expired_stamp = get_expired_stamp(certificate)
195
+ puts "Certificate for #{name} expires: #{expired_stamp.inspect}"
196
+
197
+ {
198
+ private_key: private_key,
199
+ certificate: certificate,
200
+ expired_stamp: expired_stamp
201
+ }
202
+ end
203
+ end
204
+
205
+ class HTTPChallengeHandler
206
+ def initialize
207
+ @challenges = {}
208
+ end
209
+
210
+ def add(challenge)
211
+ path = "/.well-known/acme-challenge/#{challenge.token}"
212
+ @challenges[path] = challenge
213
+ end
214
+
215
+ def remove(challenge)
216
+ path = "/.well-known/acme-challenge/#{challenge.token}"
217
+ @challenges.delete(path)
218
+ end
219
+
220
+ def call(req)
221
+ challenge = @challenges[req.path]
222
+
223
+ # handle incoming request
224
+ challenge = @challenges[req.path]
225
+ return req.respond(nil, ':status' => 400) unless challenge
226
+
227
+ req.respond(challenge.file_content, 'content-type' => challenge.content_type)
228
+ end
229
+ end
230
+
231
+ class CertificateStore
232
+ def set(name, private_key:, certificate:, expired_stamp:)
233
+ raise NotImplementedError
234
+ end
235
+
236
+ def get(name)
237
+ raise NotImplementedError
238
+ end
239
+ end
240
+
241
+ class InMemoryCertificateStore
242
+ def initialize
243
+ @store = {}
244
+ end
245
+
246
+ def set(name, private_key:, certificate:, expired_stamp:)
247
+ @store[name] = {
248
+ private_key: private_key,
249
+ certificate: certificate,
250
+ expired_stamp: expired_stamp
251
+ }
252
+ end
253
+
254
+ def get(name)
255
+ entry = @store[name]
256
+ return nil unless entry
257
+ if Time.now >= entry[:expired_stamp]
258
+ @store.delete(name)
259
+ return nil
260
+ end
261
+
262
+ entry
263
+ end
264
+ end
265
+
266
+ class SQLiteCertificateStore
267
+ attr_reader :db
268
+
269
+ def initialize(path)
270
+ require 'extralite'
271
+
272
+ @db = Extralite::Database.new(path)
273
+ @db.query("
274
+ create table if not exists certificates (
275
+ name primary key not null,
276
+ private_key not null,
277
+ certificate not null,
278
+ expired_stamp not null
279
+ );"
280
+ )
281
+ end
282
+
283
+ def set(name, private_key:, certificate:, expired_stamp:)
284
+ @db.query("
285
+ insert into certificates values (?, ?, ?, ?)
286
+ ", name, private_key.to_s, certificate, expired_stamp.to_i)
287
+ rescue Extralite::Error => e
288
+ p error_in_set: e
289
+ raise e
290
+ end
291
+
292
+ def get(name)
293
+ remove_expired_certificates
294
+
295
+ entry = @db.query_single_row("
296
+ select name, private_key, certificate, expired_stamp
297
+ from certificates
298
+ where name = ?
299
+ ", name)
300
+ return nil unless entry
301
+ entry[:expired_stamp] = Time.at(entry[:expired_stamp])
302
+ entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key])
303
+ entry
304
+ rescue Extralite::Error => e
305
+ p error_in_get: e
306
+ raise e
307
+ end
308
+
309
+ def remove_expired_certificates
310
+ @db.query("
311
+ delete from certificates
312
+ where expired_stamp < ?
313
+ ", Time.now.to_i)
314
+ rescue Extralite::Error => e
315
+ p error_in_remove_expired_certificates: e
316
+ raise e
317
+ end
318
+ end
319
+ end
320
+ end
data/lib/tipi/cli.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi'
4
+ require 'fileutils'
5
+ require 'tipi/supervisor'
6
+ require 'optparse'
7
+
8
+ module Tipi
9
+ DEFAULT_OPTS = {
10
+ app_type: :web,
11
+ mode: :polyphony,
12
+ workers: 1,
13
+ threads: 1,
14
+ listen: ['http', 'localhost', 1234],
15
+ path: '.',
16
+ }
17
+
18
+ def self.opts_from_argv(argv)
19
+ opts = DEFAULT_OPTS.dup
20
+ parser = OptionParser.new do |o|
21
+ o.banner = "Usage: tipi [options] path"
22
+ o.on('-h', '--help', 'Show this help') { puts o; exit }
23
+ o.on('-wNUM', '--workers NUM', 'Number of worker processes (default: 1)') do |v|
24
+ opts[:workers] = v
25
+ end
26
+ o.on('-tNUM', '--threads NUM', 'Number of worker threads (default: 1)') do |v|
27
+ opts[:threads] = v
28
+ opts[:mode] = :stock
29
+ end
30
+ o.on('-c', '--compatibility', 'Use compatibility mode') do
31
+ opts[:mode] = :stock
32
+ end
33
+ o.on('-lSPEC', '--listen SPEC', 'Setup HTTP listener') do |v|
34
+ opts[:listen] = parse_listen_spec('http', v)
35
+ end
36
+ o.on('-sSPEC', '--secure SPEC', 'Setup HTTPS listener (for localhost)') do |v|
37
+ opts[:listen] = parse_listen_spec('https', v)
38
+ end
39
+ o.on('-fSPEC', '--full-service SPEC', 'Setup HTTP/HTTPS listeners (with automatic certificates)') do |v|
40
+ opts[:listen] = parse_listen_spec('full', v)
41
+ end
42
+ o.on('-v', '--verbose', 'Verbose output') do
43
+ opts[:verbose] = true
44
+ end
45
+ end.parse!(argv)
46
+ opts[:path] = argv.shift unless argv.empty?
47
+ verify_path(opts[:path])
48
+ opts
49
+ end
50
+
51
+ def self.parse_listen_spec(type, spec)
52
+ [type, *spec.split(':').map { |s| str_to_native_type(s) }]
53
+ end
54
+
55
+ def self.str_to_native_type(str)
56
+ case str
57
+ when /^\d+$/
58
+ str.to_i
59
+ else
60
+ str
61
+ end
62
+ end
63
+
64
+ def self.verify_path(path)
65
+ return if File.file?(path) || File.directory?(path)
66
+
67
+ puts "Invalid path specified #{opts[:path]}"
68
+ exit!
69
+ end
70
+
71
+ module CLI
72
+ BANNER =
73
+ "\n" +
74
+ " ooo\n" +
75
+ " oo\n" +
76
+ " o\n" +
77
+ " \\|/ Tipi - a better web server for a better world\n" +
78
+ " / \\ \n" +
79
+ " / \\ https://github.com/digital-fabric/tipi\n" +
80
+ "⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺\n"
81
+
82
+ def self.start(argv = ARGV.dup)
83
+ opts = Tipi.opts_from_argv(argv)
84
+ display_banner if STDOUT.tty? && !opts[:silent]
85
+
86
+ Tipi::Supervisor.run(opts)
87
+ end
88
+
89
+ def self.display_banner
90
+ puts BANNER
91
+ end
92
+ end
93
+ end
@@ -4,28 +4,28 @@ module Tipi
4
4
  module Configuration
5
5
  class Interpreter
6
6
  # make_blank_slate
7
-
7
+
8
8
  def initialize(assembler)
9
9
  @assembler = assembler
10
10
  end
11
-
11
+
12
12
  def gzip_response
13
13
  @assembler.emit 'req = Tipi::GZip.wrap(req)'
14
14
  end
15
-
15
+
16
16
  def log(out)
17
17
  @assembler.wrap_current_frame 'logger.log_request(req) do |req|'
18
18
  end
19
-
19
+
20
20
  def error(&block)
21
21
  assembler.emit_exception_handler &block
22
22
  end
23
-
23
+
24
24
  def match(pattern, &block)
25
25
  @assembler.emit_conditional "if req.path =~ #{pattern.inspect}", &block
26
26
  end
27
27
  end
28
-
28
+
29
29
  class Assembler
30
30
  def self.from_source(code)
31
31
  new.from_source code
@@ -36,7 +36,7 @@ module Tipi
36
36
  @app_procs = {}
37
37
  @interpreter = Interpreter.new self
38
38
  @interpreter.instance_eval code
39
-
39
+
40
40
  loop do
41
41
  frame = @stack.pop
42
42
  return assemble_app_proc(frame).join("\n") if @stack.empty?
@@ -51,7 +51,7 @@ module Tipi
51
51
  body: []
52
52
  }
53
53
  end
54
-
54
+
55
55
  def add_frame(&block)
56
56
  @stack.push new_frame
57
57
  yield
@@ -67,20 +67,20 @@ module Tipi
67
67
  @stack.push wrapper
68
68
  @stack.push frame
69
69
  end
70
-
70
+
71
71
  def emit(code)
72
72
  @stack.last[:body] << code
73
73
  end
74
-
74
+
75
75
  def emit_prelude(code)
76
76
  @stack.last[:prelude] << code
77
77
  end
78
-
78
+
79
79
  def emit_exception_handler(&block)
80
80
  proc_id = add_app_proc block
81
81
  @stack.last[:rescue_proc_id] = proc_id
82
82
  end
83
-
83
+
84
84
  def emit_block(conditional, &block)
85
85
  proc_id = add_app_proc block
86
86
  @stack.last[:branched] = true
@@ -93,7 +93,7 @@ module Tipi
93
93
  @app_procs[id] = proc
94
94
  id
95
95
  end
96
-
96
+
97
97
  def assemble_frame(frame)
98
98
  indent = 0
99
99
  lines = []
@@ -12,7 +12,7 @@ module Tipi
12
12
  old_runner&.stop
13
13
  end
14
14
  end
15
-
15
+
16
16
  def run(config)
17
17
  start_listeners(config)
18
18
  config[:forked] ? forked_supervise(config) : simple_supervise(config)
@@ -29,7 +29,7 @@ module Tipi
29
29
  suspend
30
30
  # supervise(restart: true)
31
31
  end
32
-
32
+
33
33
  def forked_supervise(config)
34
34
  config[:forked].times do
35
35
  spin { Polyphony.watch_process { simple_supervise(config) } }
File without changes
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tipi
4
+ module Apps
5
+ module Bare
6
+ def start(opts)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi'
4
+
5
+ module Kernel
6
+ def run(app = nil, &block)
7
+ Tipi.app = app || block
8
+ end
9
+ end
10
+
11
+ module Tipi
12
+ class << self
13
+ attr_writer :app
14
+
15
+ def app
16
+ return @app if @app
17
+
18
+ raise 'No app define. The app to run should be set using `Tipi.app = ...`'
19
+ end
20
+
21
+ def run_sites(site_map)
22
+ sites = site_map.each_with_object({}) { |(k, v), h| h[k] = v.to_proc }
23
+ valid_hosts = sites.keys
24
+
25
+ @app = ->(req) {
26
+ handler = sites[req.host]
27
+ if handler
28
+ handler.call(req)
29
+ else
30
+ req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND)
31
+ end
32
+ }
33
+
34
+ @app.define_singleton_method(:valid_hosts) { valid_hosts }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi/http1_adapter'
4
+
5
+ module Tipi
6
+ class StockHTTP1Adapter < HTTP1Adapter
7
+ def initialize(conn, opts)
8
+ super(conn, opts)
9
+
10
+ end
11
+
12
+ def each(&block)
13
+ end
14
+ end
15
+ end