tipi 0.40 → 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 (63) 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 +5 -1
  5. data/CHANGELOG.md +35 -0
  6. data/Gemfile +7 -1
  7. data/Gemfile.lock +55 -29
  8. data/README.md +184 -8
  9. data/Rakefile +1 -3
  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 +16 -102
  18. data/df/server_utils.rb +175 -0
  19. data/examples/full_service.rb +13 -0
  20. data/examples/hello.rb +5 -0
  21. data/examples/http1_parser.rb +55 -0
  22. data/examples/http_server.js +1 -1
  23. data/examples/http_server.rb +15 -3
  24. data/examples/http_server_graceful.rb +1 -1
  25. data/examples/http_server_static.rb +6 -18
  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 +315 -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/{e → lib/tipi/controller/bare_polyphony.rb} +0 -0
  36. data/lib/tipi/controller/bare_stock.rb +10 -0
  37. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  38. data/lib/tipi/controller/web_polyphony.rb +351 -0
  39. data/lib/tipi/controller/web_stock.rb +631 -0
  40. data/lib/tipi/controller.rb +12 -0
  41. data/lib/tipi/digital_fabric/agent.rb +10 -8
  42. data/lib/tipi/digital_fabric/agent_proxy.rb +26 -12
  43. data/lib/tipi/digital_fabric/executive.rb +7 -3
  44. data/lib/tipi/digital_fabric/protocol.rb +19 -4
  45. data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
  46. data/lib/tipi/digital_fabric/service.rb +84 -56
  47. data/lib/tipi/handler.rb +2 -2
  48. data/lib/tipi/http1_adapter.rb +86 -125
  49. data/lib/tipi/http2_adapter.rb +29 -16
  50. data/lib/tipi/http2_stream.rb +52 -56
  51. data/lib/tipi/rack_adapter.rb +2 -53
  52. data/lib/tipi/response_extensions.rb +2 -2
  53. data/lib/tipi/supervisor.rb +75 -0
  54. data/lib/tipi/version.rb +1 -1
  55. data/lib/tipi/websocket.rb +3 -3
  56. data/lib/tipi.rb +8 -5
  57. data/test/coverage.rb +2 -2
  58. data/test/helper.rb +60 -12
  59. data/test/test_http_server.rb +14 -41
  60. data/test/test_request.rb +2 -29
  61. data/tipi.gemspec +12 -8
  62. metadata +88 -28
  63. data/examples/automatic_certificate.rb +0 -193
data/lib/tipi/acme.rb ADDED
@@ -0,0 +1,315 @@
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:)
14
+ @master_ctx = master_ctx
15
+ @store = store
16
+ @challenge_handler = challenge_handler
17
+ @contexts = {}
18
+ @requests = Polyphony::Queue.new
19
+ @worker = spin { run }
20
+ setup_sni_callback
21
+ end
22
+
23
+ ACME_CHALLENGE_PATH_REGEXP = /\/\.well\-known\/acme\-challenge/.freeze
24
+
25
+ def challenge_routing_app(app)
26
+ ->(req) do
27
+ (req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app)
28
+ .(req)
29
+ rescue => e
30
+ puts "Error while handling request: #{e.inspect} (headers: #{req.headers})"
31
+ req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST)
32
+ end
33
+ end
34
+
35
+ IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/
36
+
37
+ def setup_sni_callback
38
+ @master_ctx.servername_cb = proc { |_socket, name| get_ctx(name) }
39
+ end
40
+
41
+ def get_ctx(name)
42
+ state = { ctx: nil }
43
+
44
+ ready_ctx = @contexts[name]
45
+ return ready_ctx if ready_ctx
46
+ return @master_ctx if name =~ IP_REGEXP
47
+
48
+ @requests << [name, state]
49
+ wait_for_ctx(state)
50
+ # Eventually we might want to return an error returned in
51
+ # state[:error]. For the time being we handle errors by returning the
52
+ # master context
53
+ state[:ctx] || @master_ctx
54
+ rescue => e
55
+ @master_ctx
56
+ end
57
+
58
+ MAX_WAIT_FOR_CTX_DURATION = 30
59
+
60
+ def wait_for_ctx(state)
61
+ t0 = Time.now
62
+ period = 0.00001
63
+ while !state[:ctx] && !state[:error]
64
+ orig_sleep period
65
+ if period < 0.1
66
+ period *= 2
67
+ elsif Time.now - t0 > MAX_WAIT_FOR_CTX_DURATION
68
+ raise "Timeout waiting for certificate provisioning"
69
+ end
70
+ end
71
+ end
72
+
73
+ def run
74
+ loop do
75
+ name, state = @requests.shift
76
+ state[:ctx] = get_context(name)
77
+ rescue => e
78
+ state[:error] = e if state
79
+ end
80
+ end
81
+
82
+ LOCALHOST_REGEXP = /\.?localhost$/.freeze
83
+
84
+ def get_context(name)
85
+ @contexts[name] = setup_context(name)
86
+ end
87
+
88
+ def setup_context(name)
89
+ ctx = provision_context(name)
90
+ transfer_ctx_settings(ctx)
91
+ ctx
92
+ end
93
+
94
+ def provision_context(name)
95
+ return localhost_context if name =~ LOCALHOST_REGEXP
96
+
97
+ info = get_certificate(name)
98
+ ctx = OpenSSL::SSL::SSLContext.new
99
+ chain = parse_certificate(info[:certificate])
100
+ cert = chain.shift
101
+ ctx.add_certificate(cert, info[:private_key], chain)
102
+ ctx
103
+ end
104
+
105
+ def transfer_ctx_settings(ctx)
106
+ ctx.alpn_protocols = @master_ctx.alpn_protocols
107
+ ctx.alpn_select_cb = @master_ctx.alpn_select_cb
108
+ ctx.ciphers = @master_ctx.ciphers
109
+ end
110
+
111
+ CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
112
+
113
+ def parse_certificate(certificate)
114
+ certificate
115
+ .scan(CERTIFICATE_REGEXP)
116
+ .map { |p| OpenSSL::X509::Certificate.new(p.first) }
117
+ end
118
+
119
+ def get_expired_stamp(certificate)
120
+ chain = parse_certificate(certificate)
121
+ cert = chain.shift
122
+ cert.not_after
123
+ end
124
+
125
+ def get_certificate(name)
126
+ entry = @store.get(name)
127
+ return entry if entry
128
+
129
+ provision_certificate(name).tap do |entry|
130
+ @store.set(name, **entry)
131
+ end
132
+ end
133
+
134
+ def localhost_context
135
+ @localhost_authority ||= Localhost::Authority.fetch
136
+ @localhost_authority.server_context
137
+ end
138
+
139
+ def private_key
140
+ @private_key ||= OpenSSL::PKey::RSA.new(4096)
141
+ end
142
+
143
+ ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
144
+
145
+ def acme_client
146
+ @acme_client ||= setup_acme_client
147
+ end
148
+
149
+ def setup_acme_client
150
+ client = Acme::Client.new(
151
+ private_key: private_key,
152
+ directory: ACME_DIRECTORY
153
+ )
154
+ account = client.new_account(
155
+ contact: 'mailto:info@noteflakes.com',
156
+ terms_of_service_agreed: true
157
+ )
158
+ client
159
+ end
160
+
161
+ def provision_certificate(name)
162
+ order = acme_client.new_order(identifiers: [name])
163
+ authorization = order.authorizations.first
164
+ challenge = authorization.http
165
+
166
+ @challenge_handler.add(challenge)
167
+ challenge.request_validation
168
+ while challenge.status == 'pending'
169
+ sleep(0.25)
170
+ challenge.reload
171
+ end
172
+ raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
173
+
174
+ private_key = OpenSSL::PKey::RSA.new(4096)
175
+ csr = Acme::Client::CertificateRequest.new(
176
+ private_key: private_key,
177
+ subject: { common_name: name }
178
+ )
179
+ order.finalize(csr: csr)
180
+ while order.status == 'processing'
181
+ sleep(0.25)
182
+ order.reload
183
+ end
184
+ certificate = begin
185
+ order.certificate(force_chain: 'DST Root CA X3')
186
+ rescue Acme::Client::Error::ForcedChainNotFound
187
+ order.certificate
188
+ end
189
+ expired_stamp = get_expired_stamp(certificate)
190
+ puts "Certificate for #{name} expires: #{expired_stamp.inspect}"
191
+
192
+ {
193
+ private_key: private_key,
194
+ certificate: certificate,
195
+ expired_stamp: expired_stamp
196
+ }
197
+ end
198
+ end
199
+
200
+ class HTTPChallengeHandler
201
+ def initialize
202
+ @challenges = {}
203
+ end
204
+
205
+ def add(challenge)
206
+ path = "/.well-known/acme-challenge/#{challenge.token}"
207
+ @challenges[path] = challenge
208
+ end
209
+
210
+ def remove(challenge)
211
+ path = "/.well-known/acme-challenge/#{challenge.token}"
212
+ @challenges.delete(path)
213
+ end
214
+
215
+ def call(req)
216
+ challenge = @challenges[req.path]
217
+
218
+ # handle incoming request
219
+ challenge = @challenges[req.path]
220
+ return req.respond(nil, ':status' => 400) unless challenge
221
+
222
+ req.respond(challenge.file_content, 'content-type' => challenge.content_type)
223
+ end
224
+ end
225
+
226
+ class CertificateStore
227
+ def set(name, private_key:, certificate:, expired_stamp:)
228
+ raise NotImplementedError
229
+ end
230
+
231
+ def get(name)
232
+ raise NotImplementedError
233
+ end
234
+ end
235
+
236
+ class InMemoryCertificateStore
237
+ def initialize
238
+ @store = {}
239
+ end
240
+
241
+ def set(name, private_key:, certificate:, expired_stamp:)
242
+ @store[name] = {
243
+ private_key: private_key,
244
+ certificate: certificate,
245
+ expired_stamp: expired_stamp
246
+ }
247
+ end
248
+
249
+ def get(name)
250
+ entry = @store[name]
251
+ return nil unless entry
252
+ if Time.now >= entry[:expired_stamp]
253
+ @store.delete(name)
254
+ return nil
255
+ end
256
+
257
+ entry
258
+ end
259
+ end
260
+
261
+ class SQLiteCertificateStore
262
+ attr_reader :db
263
+
264
+ def initialize(path)
265
+ require 'extralite'
266
+
267
+ @db = Extralite::Database.new(path)
268
+ @db.query("
269
+ create table if not exists certificates (
270
+ name primary key not null,
271
+ private_key not null,
272
+ certificate not null,
273
+ expired_stamp not null
274
+ );"
275
+ )
276
+ end
277
+
278
+ def set(name, private_key:, certificate:, expired_stamp:)
279
+ @db.query("
280
+ insert into certificates values (?, ?, ?, ?)
281
+ ", name, private_key.to_s, certificate, expired_stamp.to_i)
282
+ rescue Extralite::Error => e
283
+ p error_in_set: e
284
+ raise e
285
+ end
286
+
287
+ def get(name)
288
+ remove_expired_certificates
289
+
290
+ entry = @db.query_single_row("
291
+ select name, private_key, certificate, expired_stamp
292
+ from certificates
293
+ where name = ?
294
+ ", name)
295
+ return nil unless entry
296
+ entry[:expired_stamp] = Time.at(entry[:expired_stamp])
297
+ entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key])
298
+ entry
299
+ rescue Extralite::Error => e
300
+ p error_in_get: e
301
+ raise e
302
+ end
303
+
304
+ def remove_expired_certificates
305
+ @db.query("
306
+ delete from certificates
307
+ where expired_stamp < ?
308
+ ", Time.now.to_i)
309
+ rescue Extralite::Error => e
310
+ p error_in_remove_expired_certificates: e
311
+ raise e
312
+ end
313
+ end
314
+ end
315
+ 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,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