tipi 0.42 → 0.47

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/test.yml +1 -3
  4. data/CHANGELOG.md +27 -0
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +35 -29
  7. data/README.md +184 -8
  8. data/Rakefile +1 -7
  9. data/benchmarks/bm_http1_parser.rb +45 -21
  10. data/bin/benchmark +0 -0
  11. data/bin/h1pd +0 -0
  12. data/bm.png +0 -0
  13. data/df/agent.rb +1 -1
  14. data/df/sample_agent.rb +2 -2
  15. data/df/server.rb +2 -0
  16. data/df/server_utils.rb +12 -15
  17. data/examples/hello.rb +5 -0
  18. data/examples/hello.ru +3 -3
  19. data/examples/http_server.js +1 -1
  20. data/examples/http_server_graceful.rb +1 -1
  21. data/examples/https_server.rb +41 -18
  22. data/examples/rack_server_forked.rb +26 -0
  23. data/examples/rack_server_https_forked.rb +1 -1
  24. data/examples/websocket_demo.rb +1 -1
  25. data/lib/tipi/acme.rb +51 -39
  26. data/lib/tipi/cli.rb +79 -16
  27. data/lib/tipi/config_dsl.rb +13 -13
  28. data/lib/tipi/configuration.rb +2 -2
  29. data/lib/tipi/controller/bare_polyphony.rb +0 -0
  30. data/lib/tipi/controller/bare_stock.rb +10 -0
  31. data/lib/tipi/controller/extensions.rb +37 -0
  32. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  33. data/lib/tipi/controller/web_polyphony.rb +353 -0
  34. data/lib/tipi/controller/web_stock.rb +635 -0
  35. data/lib/tipi/controller.rb +12 -0
  36. data/lib/tipi/digital_fabric/agent.rb +3 -3
  37. data/lib/tipi/digital_fabric/agent_proxy.rb +11 -5
  38. data/lib/tipi/digital_fabric/executive.rb +1 -1
  39. data/lib/tipi/digital_fabric/protocol.rb +1 -1
  40. data/lib/tipi/digital_fabric/service.rb +12 -8
  41. data/lib/tipi/handler.rb +2 -2
  42. data/lib/tipi/http1_adapter.rb +36 -30
  43. data/lib/tipi/http2_adapter.rb +10 -10
  44. data/lib/tipi/http2_stream.rb +14 -15
  45. data/lib/tipi/rack_adapter.rb +2 -2
  46. data/lib/tipi/response_extensions.rb +1 -1
  47. data/lib/tipi/supervisor.rb +75 -0
  48. data/lib/tipi/version.rb +1 -1
  49. data/lib/tipi/websocket.rb +3 -3
  50. data/lib/tipi.rb +4 -83
  51. data/test/coverage.rb +2 -2
  52. data/test/helper.rb +0 -1
  53. data/test/test_http_server.rb +14 -14
  54. data/test/test_request.rb +1 -1
  55. data/tipi.gemspec +6 -7
  56. metadata +58 -53
  57. data/ext/tipi/extconf.rb +0 -13
  58. data/ext/tipi/http1_parser.c +0 -823
  59. data/ext/tipi/http1_parser.h +0 -18
  60. data/ext/tipi/tipi_ext.c +0 -5
  61. data/security/http1.rb +0 -12
  62. data/test/test_http1_parser.rb +0 -586
@@ -10,27 +10,50 @@ authority = Localhost::Authority.fetch
10
10
  opts = {
11
11
  reuse_addr: true,
12
12
  dont_linger: true,
13
- secure_context: authority.server_context
14
13
  }
15
14
 
16
15
  puts "pid: #{Process.pid}"
17
16
  puts 'Listening on port 1234...'
18
- Tipi.serve('0.0.0.0', 1234, opts) do |req|
19
- p path: req.path
20
- if req.path == '/stream'
21
- req.send_headers('Foo' => 'Bar')
22
- sleep 0.5
23
- req.send_chunk("foo\n")
24
- sleep 0.5
25
- req.send_chunk("bar\n", done: true)
26
- elsif req.path == '/upload'
27
- body = req.read
28
- req.respond("Body: #{body.inspect} (#{body.bytesize} bytes)")
29
- else
30
- req.respond("Hello world!\n")
17
+
18
+ ctx = authority.server_context
19
+ server = Polyphony::Net.tcp_listen('0.0.0.0', 1234, opts)
20
+ loop do
21
+ socket = server.accept
22
+ client = OpenSSL::SSL::SSLSocket.new(socket, ctx)
23
+ client.sync_close = true
24
+ spin do
25
+ state = {}
26
+ accept_thread = Thread.new do
27
+ puts "call client accept"
28
+ client.accept
29
+ state[:result] = :ok
30
+ rescue Exception => e
31
+ puts error: e
32
+ state[:result] = e
33
+ end
34
+ "wait for accept thread"
35
+ accept_thread.join
36
+ "accept thread done"
37
+ if state[:result].is_a?(Exception)
38
+ puts "Exception in SSL handshake: #{state[:result].inspect}"
39
+ next
40
+ end
41
+ Tipi.client_loop(client, opts) do |req|
42
+ p path: req.path
43
+ if req.path == '/stream'
44
+ req.send_headers('Foo' => 'Bar')
45
+ sleep 0.5
46
+ req.send_chunk("foo\n")
47
+ sleep 0.5
48
+ req.send_chunk("bar\n", done: true)
49
+ elsif req.path == '/upload'
50
+ body = req.read
51
+ req.respond("Body: #{body.inspect} (#{body.bytesize} bytes)")
52
+ else
53
+ req.respond("Hello world!\n")
54
+ end
55
+ end
56
+ ensure
57
+ client ? client.close : socket.close
31
58
  end
32
- # req.send_headers
33
- # req.send_chunk("Method: #{req.method}\n")
34
- # req.send_chunk("Path: #{req.path}\n")
35
- # req.send_chunk("Query: #{req.query.inspect}\n", done: true)
36
59
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'tipi'
5
+
6
+ app_path = ARGV.first || File.expand_path('./config.ru', __dir__)
7
+ unless File.file?(app_path)
8
+ STDERR.puts "Please provide rack config file (there are some in the examples directory.)"
9
+ exit!
10
+ end
11
+
12
+ app = Tipi::RackAdapter.load(app_path)
13
+ opts = { reuse_addr: true, dont_linger: true }
14
+
15
+ server = Tipi.listen('0.0.0.0', 1234, opts)
16
+ puts 'listening on port 1234'
17
+
18
+ child_pids = []
19
+ 4.times do
20
+ child_pids << Polyphony.fork do
21
+ puts "forked pid: #{Process.pid}"
22
+ server.each(&app)
23
+ end
24
+ end
25
+
26
+ child_pids.each { |pid| Thread.current.backend.waitpid(pid) }
@@ -25,4 +25,4 @@ child_pids = []
25
25
  end
26
26
  end
27
27
 
28
- child_pids.each { |pid| Thread.current.backend.waitpid(pid) }
28
+ child_pids.each { |pid| Thread.current.backend.waitpid(pid) }
@@ -26,7 +26,7 @@ class WebsocketClient
26
26
  def receive
27
27
  parsed = @reader.next
28
28
  return parsed if parsed
29
-
29
+
30
30
  @socket.read_loop do |data|
31
31
  @reader << data
32
32
  parsed = @reader.next
data/lib/tipi/acme.rb CHANGED
@@ -10,10 +10,11 @@ module Tipi
10
10
  end
11
11
 
12
12
  class CertificateManager
13
- def initialize(master_ctx:, store:, challenge_handler:)
13
+ def initialize(master_ctx:, store:, challenge_handler:, valid_hosts:)
14
14
  @master_ctx = master_ctx
15
15
  @store = store
16
16
  @challenge_handler = challenge_handler
17
+ @valid_hosts = valid_hosts
17
18
  @contexts = {}
18
19
  @requests = Polyphony::Queue.new
19
20
  @worker = spin { run }
@@ -35,32 +36,45 @@ module Tipi
35
36
  IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/
36
37
 
37
38
  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
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)
53
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
54
61
  end
55
-
62
+
63
+ MAX_WAIT_FOR_CTX_DURATION = 30
64
+
56
65
  def wait_for_ctx(state)
66
+ t0 = Time.now
57
67
  period = 0.00001
58
68
  while !state[:ctx] && !state[:error]
59
69
  orig_sleep period
60
- period *= 2 if period < 0.1
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
61
75
  end
62
76
  end
63
-
77
+
64
78
  def run
65
79
  loop do
66
80
  name, state = @requests.shift
@@ -69,13 +83,13 @@ module Tipi
69
83
  state[:error] = e if state
70
84
  end
71
85
  end
72
-
86
+
73
87
  LOCALHOST_REGEXP = /\.?localhost$/.freeze
74
88
 
75
89
  def get_context(name)
76
90
  @contexts[name] = setup_context(name)
77
91
  end
78
-
92
+
79
93
  def setup_context(name)
80
94
  ctx = provision_context(name)
81
95
  transfer_ctx_settings(ctx)
@@ -100,7 +114,7 @@ module Tipi
100
114
  end
101
115
 
102
116
  CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
103
-
117
+
104
118
  def parse_certificate(certificate)
105
119
  certificate
106
120
  .scan(CERTIFICATE_REGEXP)
@@ -126,17 +140,17 @@ module Tipi
126
140
  @localhost_authority ||= Localhost::Authority.fetch
127
141
  @localhost_authority.server_context
128
142
  end
129
-
143
+
130
144
  def private_key
131
145
  @private_key ||= OpenSSL::PKey::RSA.new(4096)
132
146
  end
133
-
147
+
134
148
  ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
135
-
149
+
136
150
  def acme_client
137
151
  @acme_client ||= setup_acme_client
138
152
  end
139
-
153
+
140
154
  def setup_acme_client
141
155
  client = Acme::Client.new(
142
156
  private_key: private_key,
@@ -148,13 +162,12 @@ module Tipi
148
162
  )
149
163
  client
150
164
  end
151
-
165
+
152
166
  def provision_certificate(name)
153
- p provision_certificate: name
154
167
  order = acme_client.new_order(identifiers: [name])
155
168
  authorization = order.authorizations.first
156
169
  challenge = authorization.http
157
-
170
+
158
171
  @challenge_handler.add(challenge)
159
172
  challenge.request_validation
160
173
  while challenge.status == 'pending'
@@ -162,8 +175,7 @@ module Tipi
162
175
  challenge.reload
163
176
  end
164
177
  raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
165
-
166
- p challenge_status: challenge.status
178
+
167
179
  private_key = OpenSSL::PKey::RSA.new(4096)
168
180
  csr = Acme::Client::CertificateRequest.new(
169
181
  private_key: private_key,
@@ -189,33 +201,33 @@ module Tipi
189
201
  }
190
202
  end
191
203
  end
192
-
204
+
193
205
  class HTTPChallengeHandler
194
206
  def initialize
195
207
  @challenges = {}
196
208
  end
197
-
209
+
198
210
  def add(challenge)
199
211
  path = "/.well-known/acme-challenge/#{challenge.token}"
200
212
  @challenges[path] = challenge
201
213
  end
202
-
214
+
203
215
  def remove(challenge)
204
216
  path = "/.well-known/acme-challenge/#{challenge.token}"
205
217
  @challenges.delete(path)
206
218
  end
207
-
219
+
208
220
  def call(req)
209
221
  challenge = @challenges[req.path]
210
-
222
+
211
223
  # handle incoming request
212
224
  challenge = @challenges[req.path]
213
225
  return req.respond(nil, ':status' => 400) unless challenge
214
-
226
+
215
227
  req.respond(challenge.file_content, 'content-type' => challenge.content_type)
216
228
  end
217
- end
218
-
229
+ end
230
+
219
231
  class CertificateStore
220
232
  def set(name, private_key:, certificate:, expired_stamp:)
221
233
  raise NotImplementedError
data/lib/tipi/cli.rb CHANGED
@@ -2,29 +2,92 @@
2
2
 
3
3
  require 'tipi'
4
4
  require 'fileutils'
5
+ require 'tipi/supervisor'
6
+ require 'optparse'
5
7
 
6
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
+
7
71
  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)
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)
21
87
  end
22
88
 
23
89
  def self.display_banner
24
90
  puts BANNER
25
- puts
26
91
  end
27
92
  end
28
93
  end
29
-
30
- __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