tipi 0.42 → 0.47

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