tipi 0.41 → 0.46

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