tipi 0.39 → 0.43

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/.gitignore +5 -1
  4. data/CHANGELOG.md +30 -0
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +62 -25
  7. data/Rakefile +7 -3
  8. data/benchmarks/bm_http1_parser.rb +85 -0
  9. data/bin/benchmark +37 -0
  10. data/bin/h1pd +6 -0
  11. data/bin/tipi +3 -21
  12. data/df/server.rb +16 -87
  13. data/df/server_utils.rb +175 -0
  14. data/examples/full_service.rb +13 -0
  15. data/examples/http1_parser.rb +55 -0
  16. data/examples/http_server.rb +15 -3
  17. data/examples/http_server_forked.rb +3 -1
  18. data/examples/http_server_routes.rb +29 -0
  19. data/examples/http_server_static.rb +26 -0
  20. data/examples/https_server.rb +3 -0
  21. data/examples/servername_cb.rb +37 -0
  22. data/examples/websocket_demo.rb +2 -8
  23. data/examples/ws_page.html +2 -2
  24. data/lib/tipi.rb +89 -1
  25. data/lib/tipi/acme.rb +308 -0
  26. data/lib/tipi/cli.rb +30 -0
  27. data/lib/tipi/digital_fabric/agent.rb +7 -5
  28. data/lib/tipi/digital_fabric/agent_proxy.rb +16 -8
  29. data/lib/tipi/digital_fabric/executive.rb +6 -2
  30. data/lib/tipi/digital_fabric/protocol.rb +18 -3
  31. data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
  32. data/lib/tipi/digital_fabric/service.rb +77 -49
  33. data/lib/tipi/http1_adapter.rb +91 -100
  34. data/lib/tipi/http2_adapter.rb +21 -6
  35. data/lib/tipi/http2_stream.rb +54 -44
  36. data/lib/tipi/rack_adapter.rb +2 -53
  37. data/lib/tipi/response_extensions.rb +17 -0
  38. data/lib/tipi/version.rb +1 -1
  39. data/test/helper.rb +60 -12
  40. data/test/test_http_server.rb +0 -27
  41. data/test/test_request.rb +2 -29
  42. data/tipi.gemspec +11 -7
  43. metadata +79 -26
  44. data/e +0 -0
data/lib/tipi/acme.rb ADDED
@@ -0,0 +1,308 @@
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 do |_socket, name|
39
+ p servername_cb: name
40
+ state = { ctx: nil }
41
+
42
+ if name =~ IP_REGEXP
43
+ @master_ctx
44
+ else
45
+ @requests << [name, state]
46
+ wait_for_ctx(state)
47
+ p name: name, error: state if state[:error]
48
+ # Eventually we might want to return an error returned in
49
+ # state[:error]. For the time being we handle errors by returning the
50
+ # master context
51
+ state[:ctx] || @master_ctx
52
+ end
53
+ end
54
+ end
55
+
56
+ def wait_for_ctx(state)
57
+ period = 0.00001
58
+ while !state[:ctx] && !state[:error]
59
+ orig_sleep period
60
+ period *= 2 if period < 0.1
61
+ end
62
+ end
63
+
64
+ def run
65
+ loop do
66
+ name, state = @requests.shift
67
+ state[:ctx] = get_context(name)
68
+ rescue => e
69
+ state[:error] = e if state
70
+ end
71
+ end
72
+
73
+ LOCALHOST_REGEXP = /\.?localhost$/.freeze
74
+
75
+ def get_context(name)
76
+ @contexts[name] = setup_context(name)
77
+ end
78
+
79
+ def setup_context(name)
80
+ ctx = provision_context(name)
81
+ transfer_ctx_settings(ctx)
82
+ ctx
83
+ end
84
+
85
+ def provision_context(name)
86
+ return localhost_context if name =~ LOCALHOST_REGEXP
87
+
88
+ info = get_certificate(name)
89
+ ctx = OpenSSL::SSL::SSLContext.new
90
+ chain = parse_certificate(info[:certificate])
91
+ cert = chain.shift
92
+ ctx.add_certificate(cert, info[:private_key], chain)
93
+ ctx
94
+ end
95
+
96
+ def transfer_ctx_settings(ctx)
97
+ ctx.alpn_protocols = @master_ctx.alpn_protocols
98
+ ctx.alpn_select_cb = @master_ctx.alpn_select_cb
99
+ ctx.ciphers = @master_ctx.ciphers
100
+ end
101
+
102
+ CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
103
+
104
+ def parse_certificate(certificate)
105
+ certificate
106
+ .scan(CERTIFICATE_REGEXP)
107
+ .map { |p| OpenSSL::X509::Certificate.new(p.first) }
108
+ end
109
+
110
+ def get_expired_stamp(certificate)
111
+ chain = parse_certificate(certificate)
112
+ cert = chain.shift
113
+ cert.not_after
114
+ end
115
+
116
+ def get_certificate(name)
117
+ entry = @store.get(name)
118
+ return entry if entry
119
+
120
+ provision_certificate(name).tap do |entry|
121
+ @store.set(name, **entry)
122
+ end
123
+ end
124
+
125
+ def localhost_context
126
+ @localhost_authority ||= Localhost::Authority.fetch
127
+ @localhost_authority.server_context
128
+ end
129
+
130
+ def private_key
131
+ @private_key ||= OpenSSL::PKey::RSA.new(4096)
132
+ end
133
+
134
+ ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
135
+
136
+ def acme_client
137
+ @acme_client ||= setup_acme_client
138
+ end
139
+
140
+ def setup_acme_client
141
+ client = Acme::Client.new(
142
+ private_key: private_key,
143
+ directory: ACME_DIRECTORY
144
+ )
145
+ account = client.new_account(
146
+ contact: 'mailto:info@noteflakes.com',
147
+ terms_of_service_agreed: true
148
+ )
149
+ client
150
+ end
151
+
152
+ def provision_certificate(name)
153
+ p provision_certificate: name
154
+ order = acme_client.new_order(identifiers: [name])
155
+ authorization = order.authorizations.first
156
+ challenge = authorization.http
157
+
158
+ @challenge_handler.add(challenge)
159
+ challenge.request_validation
160
+ while challenge.status == 'pending'
161
+ sleep(0.25)
162
+ challenge.reload
163
+ end
164
+ raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
165
+
166
+ p challenge_status: challenge.status
167
+ private_key = OpenSSL::PKey::RSA.new(4096)
168
+ csr = Acme::Client::CertificateRequest.new(
169
+ private_key: private_key,
170
+ subject: { common_name: name }
171
+ )
172
+ order.finalize(csr: csr)
173
+ while order.status == 'processing'
174
+ sleep(0.25)
175
+ order.reload
176
+ end
177
+ certificate = begin
178
+ order.certificate(force_chain: 'DST Root CA X3')
179
+ rescue Acme::Client::Error::ForcedChainNotFound
180
+ order.certificate
181
+ end
182
+ expired_stamp = get_expired_stamp(certificate)
183
+ puts "Certificate for #{name} expires: #{expired_stamp.inspect}"
184
+
185
+ {
186
+ private_key: private_key,
187
+ certificate: certificate,
188
+ expired_stamp: expired_stamp
189
+ }
190
+ end
191
+ end
192
+
193
+ class HTTPChallengeHandler
194
+ def initialize
195
+ @challenges = {}
196
+ end
197
+
198
+ def add(challenge)
199
+ path = "/.well-known/acme-challenge/#{challenge.token}"
200
+ @challenges[path] = challenge
201
+ end
202
+
203
+ def remove(challenge)
204
+ path = "/.well-known/acme-challenge/#{challenge.token}"
205
+ @challenges.delete(path)
206
+ end
207
+
208
+ def call(req)
209
+ challenge = @challenges[req.path]
210
+
211
+ # handle incoming request
212
+ challenge = @challenges[req.path]
213
+ return req.respond(nil, ':status' => 400) unless challenge
214
+
215
+ req.respond(challenge.file_content, 'content-type' => challenge.content_type)
216
+ end
217
+ end
218
+
219
+ class CertificateStore
220
+ def set(name, private_key:, certificate:, expired_stamp:)
221
+ raise NotImplementedError
222
+ end
223
+
224
+ def get(name)
225
+ raise NotImplementedError
226
+ end
227
+ end
228
+
229
+ class InMemoryCertificateStore
230
+ def initialize
231
+ @store = {}
232
+ end
233
+
234
+ def set(name, private_key:, certificate:, expired_stamp:)
235
+ @store[name] = {
236
+ private_key: private_key,
237
+ certificate: certificate,
238
+ expired_stamp: expired_stamp
239
+ }
240
+ end
241
+
242
+ def get(name)
243
+ entry = @store[name]
244
+ return nil unless entry
245
+ if Time.now >= entry[:expired_stamp]
246
+ @store.delete(name)
247
+ return nil
248
+ end
249
+
250
+ entry
251
+ end
252
+ end
253
+
254
+ class SQLiteCertificateStore
255
+ attr_reader :db
256
+
257
+ def initialize(path)
258
+ require 'extralite'
259
+
260
+ @db = Extralite::Database.new(path)
261
+ @db.query("
262
+ create table if not exists certificates (
263
+ name primary key not null,
264
+ private_key not null,
265
+ certificate not null,
266
+ expired_stamp not null
267
+ );"
268
+ )
269
+ end
270
+
271
+ def set(name, private_key:, certificate:, expired_stamp:)
272
+ @db.query("
273
+ insert into certificates values (?, ?, ?, ?)
274
+ ", name, private_key.to_s, certificate, expired_stamp.to_i)
275
+ rescue Extralite::Error => e
276
+ p error_in_set: e
277
+ raise e
278
+ end
279
+
280
+ def get(name)
281
+ remove_expired_certificates
282
+
283
+ entry = @db.query_single_row("
284
+ select name, private_key, certificate, expired_stamp
285
+ from certificates
286
+ where name = ?
287
+ ", name)
288
+ return nil unless entry
289
+ entry[:expired_stamp] = Time.at(entry[:expired_stamp])
290
+ entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key])
291
+ entry
292
+ rescue Extralite::Error => e
293
+ p error_in_get: e
294
+ raise e
295
+ end
296
+
297
+ def remove_expired_certificates
298
+ @db.query("
299
+ delete from certificates
300
+ where expired_stamp < ?
301
+ ", Time.now.to_i)
302
+ rescue Extralite::Error => e
303
+ p error_in_remove_expired_certificates: e
304
+ raise e
305
+ end
306
+ end
307
+ end
308
+ end
data/lib/tipi/cli.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi'
4
+ require 'fileutils'
5
+
6
+ module Tipi
7
+ module CLI
8
+ BANNER = "
9
+ ooo
10
+ oo
11
+ o
12
+ \\|/ Tipi - a better web server for a better world
13
+ / \\
14
+ / \\ https://github.com/digital-fabric/tipi
15
+ ⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
16
+ "
17
+
18
+ def self.start
19
+ display_banner
20
+ require File.expand_path(ARGV[0] || 'app.rb', FileUtils.pwd)
21
+ end
22
+
23
+ def self.display_banner
24
+ puts BANNER
25
+ puts
26
+ end
27
+ end
28
+ end
29
+
30
+ __END__
@@ -24,9 +24,11 @@ module DigitalFabric
24
24
  class GracefulShutdown < RuntimeError
25
25
  end
26
26
 
27
+ @@id = 0
28
+
27
29
  def run
28
30
  @fiber = Fiber.current
29
- @keep_alive_timer = spin_loop(interval: 5) { keep_alive }
31
+ @keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive }
30
32
  while true
31
33
  connect_and_process_incoming_requests
32
34
  return if @shutdown
@@ -166,12 +168,13 @@ module DigitalFabric
166
168
  def recv_http_request(msg)
167
169
  req = prepare_http_request(msg)
168
170
  id = msg[Protocol::Attribute::ID]
169
- @requests[id] = spin do
171
+ @requests[id] = spin("#{Fiber.current.tag}.#{id}") do
170
172
  http_request(req)
171
173
  rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE
172
174
  # ignore
173
- rescue Polyphony::Terminate
175
+ rescue Polyphony::Terminate => e
174
176
  req.respond(nil, { ':status' => Qeweney::Status::SERVICE_UNAVAILABLE }) if Fiber.current.graceful_shutdown?
177
+ raise e
175
178
  ensure
176
179
  @requests.delete(id)
177
180
  @long_running_requests.delete(id)
@@ -185,7 +188,6 @@ module DigitalFabric
185
188
  complete = msg[Protocol::Attribute::HttpRequest::COMPLETE]
186
189
  req = Qeweney::Request.new(headers, RequestAdapter.new(self, msg))
187
190
  req.buffer_body_chunk(body_chunk) if body_chunk
188
- req.complete! if complete
189
191
  req
190
192
  end
191
193
 
@@ -204,7 +206,7 @@ module DigitalFabric
204
206
  def recv_ws_request(msg)
205
207
  req = Qeweney::Request.new(msg[Protocol::Attribute::WS::HEADERS], RequestAdapter.new(self, msg))
206
208
  id = msg[Protocol::Attribute::ID]
207
- @requests[id] = @long_running_requests[id] = spin do
209
+ @requests[id] = @long_running_requests[id] = spin("#{Fiber.current.tag}.#{id}-ws") do
208
210
  ws_request(req)
209
211
  rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE
210
212
  # ignore
@@ -32,13 +32,13 @@ module DigitalFabric
32
32
  @fiber = Fiber.current
33
33
  @service.mount(route, self)
34
34
  @mounted = true
35
- keep_alive_timer = spin_loop(interval: 5) { keep_alive }
35
+ # keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive }
36
36
  process_incoming_messages(false)
37
37
  rescue GracefulShutdown
38
38
  puts "Proxy got graceful shutdown, left: #{@requests.size} requests" if @requests.size > 0
39
- process_incoming_messages(true)
39
+ move_on_after(15) { process_incoming_messages(true) }
40
40
  ensure
41
- keep_alive_timer&.stop
41
+ # keep_alive_timer&.stop
42
42
  unmount
43
43
  end
44
44
 
@@ -60,7 +60,7 @@ module DigitalFabric
60
60
  @mounted = nil
61
61
  end
62
62
 
63
- def shutdown
63
+ def send_shutdown
64
64
  send_df_message(Protocol.shutdown)
65
65
  @fiber.raise GracefulShutdown.new
66
66
  end
@@ -98,6 +98,8 @@ module DigitalFabric
98
98
  return
99
99
  when Protocol::UNMOUNT
100
100
  return unmount
101
+ when Protocol::STATS_REQUEST
102
+ return handle_stats_request(message[Protocol::Attribute::ID])
101
103
  end
102
104
 
103
105
  handler = @requests[message[Protocol::Attribute::ID]]
@@ -142,11 +144,12 @@ module DigitalFabric
142
144
  t0 = Time.now
143
145
  t1 = nil
144
146
  with_request do |id|
145
- send_df_message(Protocol.http_request(id, req))
147
+ msg = Protocol.http_request(id, req.headers, req.next_chunk(true), req.complete?)
148
+ send_df_message(msg)
146
149
  while (message = receive)
147
150
  unless t1
148
151
  t1 = Time.now
149
- @service.record_latency_measurement(t1 - t0)
152
+ @service.record_latency_measurement(t1 - t0, req)
150
153
  end
151
154
  kind = message[Protocol::Attribute::KIND]
152
155
  attributes = message[Protocol::Attribute::HttpRequest::HEADERS..-1]
@@ -187,6 +190,11 @@ module DigitalFabric
187
190
  send_df_message(Protocol.transfer_count(key, rx, tx))
188
191
  end
189
192
 
193
+ def handle_stats_request(id)
194
+ stats = @service.get_stats
195
+ send_df_message(Protocol.stats_response(id, stats))
196
+ end
197
+
190
198
  HTTP_RESPONSE_UPGRADE_HEADERS = { ':status' => Qeweney::Status::SWITCHING_PROTOCOLS }
191
199
 
192
200
  def http_custom_upgrade(id, req, headers)
@@ -197,7 +205,7 @@ module DigitalFabric
197
205
  req.send_headers(upgrade_headers, true)
198
206
 
199
207
  conn = req.adapter.conn
200
- reader = spin do
208
+ reader = spin("#{Fiber.current.tag}.#{id}") do
201
209
  conn.recv_loop do |data|
202
210
  send_df_message(Protocol.conn_data(id, data))
203
211
  end
@@ -294,7 +302,7 @@ module DigitalFabric
294
302
  end
295
303
 
296
304
  def run_websocket_connection(id, websocket)
297
- reader = spin do
305
+ reader = spin("#{Fiber.current}.#{id}-ws") do
298
306
  websocket.recv_loop do |data|
299
307
  send_df_message(Protocol.ws_data(id, data))
300
308
  end