tipi 0.40 → 0.45

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