itsi 0.2.26 → 0.2.27

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +7 -3
  4. data/README.md +4 -1
  5. data/Rakefile +24 -3
  6. data/crates/itsi_acme/Cargo.toml +2 -1
  7. data/crates/itsi_acme/src/acceptor.rs +1 -1
  8. data/crates/itsi_acme/src/acme.rs +31 -3
  9. data/crates/itsi_acme/src/http_challenge.rs +81 -0
  10. data/crates/itsi_acme/src/https_helper.rs +3 -1
  11. data/crates/itsi_acme/src/jose.rs +6 -2
  12. data/crates/itsi_acme/src/lib.rs +2 -0
  13. data/crates/itsi_acme/src/resolver.rs +27 -4
  14. data/crates/itsi_acme/src/state.rs +183 -22
  15. data/crates/itsi_scheduler/Cargo.toml +1 -1
  16. data/crates/itsi_scheduler/src/itsi_scheduler.rs +115 -64
  17. data/crates/itsi_scheduler/src/lib.rs +2 -1
  18. data/crates/itsi_server/Cargo.toml +2 -1
  19. data/crates/itsi_server/src/lib.rs +15 -0
  20. data/crates/itsi_server/src/ruby_types/itsi_grpc_call.rs +0 -1
  21. data/crates/itsi_server/src/ruby_types/itsi_http_request.rs +9 -0
  22. data/crates/itsi_server/src/ruby_types/itsi_http_response.rs +114 -4
  23. data/crates/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +22 -1
  24. data/crates/itsi_server/src/ruby_types/itsi_server.rs +100 -0
  25. data/crates/itsi_server/src/server/binds/listener.rs +9 -24
  26. data/crates/itsi_server/src/server/binds/tls.rs +372 -67
  27. data/crates/itsi_server/src/server/signal.rs +14 -9
  28. data/crates/itsi_server/src/services/itsi_http_service.rs +46 -2
  29. data/docs/content/features/_index.md +3 -0
  30. data/docs/content/getting_started/_index.md +6 -2
  31. data/docs/content/getting_started/local_development.md +13 -0
  32. data/docs/content/itsi_scheduler/_index.md +3 -0
  33. data/gems/scheduler/Cargo.lock +4011 -527
  34. data/gems/scheduler/Gemfile +8 -2
  35. data/gems/scheduler/Gemfile.lock +107 -0
  36. data/gems/scheduler/Rakefile +33 -9
  37. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  38. data/gems/scheduler/lib/itsi/scheduler.rb +121 -6
  39. data/gems/scheduler/test/helpers/test_helper.rb +2 -0
  40. data/gems/scheduler/test/test_address_resolve.rb +8 -2
  41. data/gems/scheduler/test/test_itsi_scheduler.rb +80 -0
  42. data/gems/scheduler/test/test_timeout_after.rb +102 -0
  43. data/gems/server/Cargo.lock +30 -1
  44. data/gems/server/Gemfile +3 -0
  45. data/gems/server/Gemfile.lock +195 -0
  46. data/gems/server/Rakefile +18 -5
  47. data/gems/server/lib/itsi/http_request.rb +10 -0
  48. data/gems/server/lib/itsi/server/config/options/certificates.md +37 -9
  49. data/gems/server/lib/itsi/server/config/options/fiber_scheduler.md +2 -0
  50. data/gems/server/lib/itsi/server/default_config/Itsi.rb +4 -0
  51. data/gems/server/lib/itsi/server/rack_interface.rb +47 -3
  52. data/gems/server/lib/itsi/server/version.rb +1 -1
  53. data/gems/server/lib/itsi/server.rb +24 -0
  54. data/gems/server/test/acme/local_acme_challenges.rb +190 -0
  55. data/gems/server/test/helpers/local_acme.rb +218 -0
  56. data/gems/server/test/helpers/test_helper.rb +7 -9
  57. data/gems/server/test/middleware/endpoint.rb +9 -6
  58. data/gems/server/test/rack/test_rack_server.rb +144 -0
  59. data/lib/itsi/version.rb +1 -1
  60. metadata +12 -6
@@ -0,0 +1,195 @@
1
+ PATH
2
+ remote: ../scheduler
3
+ specs:
4
+ itsi-scheduler (0.2.27)
5
+ rb_sys (~> 0.9.91)
6
+
7
+ PATH
8
+ remote: .
9
+ specs:
10
+ itsi-server (0.2.27)
11
+ json (~> 2)
12
+ prism (~> 1.4)
13
+ rack (>= 1.6)
14
+ rb_sys (~> 0.9.91)
15
+
16
+ GEM
17
+ remote: https://rubygems.org/
18
+ specs:
19
+ ansi (1.6.0)
20
+ async (2.39.0)
21
+ console (~> 1.29)
22
+ fiber-annotation
23
+ io-event (~> 1.11)
24
+ metrics (~> 0.12)
25
+ traces (~> 0.18)
26
+ async-http (0.95.1)
27
+ async (>= 2.10.2)
28
+ async-pool (~> 0.11)
29
+ io-endpoint (~> 0.14)
30
+ io-stream (~> 0.6)
31
+ metrics (~> 0.12)
32
+ protocol-http (~> 0.62)
33
+ protocol-http1 (~> 0.39)
34
+ protocol-http2 (~> 0.26)
35
+ protocol-url (~> 0.2)
36
+ traces (~> 0.10)
37
+ async-pool (0.11.2)
38
+ async (>= 2.0)
39
+ async-websocket (0.30.1)
40
+ async-http (~> 0.76)
41
+ protocol-http (~> 0.34)
42
+ protocol-rack (~> 0.7)
43
+ protocol-websocket (~> 0.17)
44
+ base64 (0.3.0)
45
+ bigdecimal (4.1.2)
46
+ builder (3.3.0)
47
+ connection_pool (3.0.2)
48
+ console (1.36.0)
49
+ fiber-annotation
50
+ fiber-local (~> 1.1)
51
+ json
52
+ fiber-annotation (0.2.0)
53
+ fiber-local (1.1.0)
54
+ fiber-storage
55
+ fiber-storage (1.0.1)
56
+ google-protobuf (4.30.2)
57
+ bigdecimal
58
+ rake (>= 13)
59
+ googleapis-common-protos-types (1.19.0)
60
+ google-protobuf (>= 3.18, < 5.a)
61
+ grpc (1.78.0)
62
+ google-protobuf (>= 3.25, < 5.0)
63
+ googleapis-common-protos-types (~> 1.0)
64
+ io-endpoint (0.17.2)
65
+ io-event (1.16.2)
66
+ io-stream (0.13.1)
67
+ json (2.19.4)
68
+ jwt (3.1.2)
69
+ base64
70
+ language_server-protocol (3.17.0.5)
71
+ logger (1.7.0)
72
+ metrics (0.15.0)
73
+ minitest (5.27.0)
74
+ minitest-reporters (1.8.0)
75
+ ansi
76
+ builder
77
+ minitest (>= 5.0, < 7)
78
+ ruby-progressbar
79
+ net_http_unix (0.2.2)
80
+ prism (1.9.0)
81
+ protocol-hpack (1.5.1)
82
+ protocol-http (0.62.2)
83
+ protocol-http1 (0.39.0)
84
+ protocol-http (~> 0.62)
85
+ protocol-http2 (0.26.0)
86
+ protocol-hpack (~> 1.4)
87
+ protocol-http (~> 0.62)
88
+ protocol-rack (0.22.1)
89
+ io-stream (>= 0.10)
90
+ protocol-http (~> 0.58)
91
+ rack (>= 1.0)
92
+ protocol-url (0.4.0)
93
+ protocol-websocket (0.21.1)
94
+ protocol-http (~> 0.2)
95
+ rack (3.2.6)
96
+ rackup (2.3.1)
97
+ rack (>= 3)
98
+ rake (13.3.1)
99
+ rake-compiler (1.3.0)
100
+ rake
101
+ rake-compiler-dock (1.11.0)
102
+ rb_sys (0.9.126)
103
+ json (>= 2)
104
+ rake-compiler-dock (= 1.11.0)
105
+ rbs (3.10.3)
106
+ logger
107
+ tsort
108
+ redis (5.4.1)
109
+ redis-client (>= 0.22.0)
110
+ redis-client (0.24.0)
111
+ connection_pool
112
+ ruby-lsp (0.26.7)
113
+ language_server-protocol (~> 3.17.0)
114
+ prism (>= 1.2, < 2.0)
115
+ rbs (>= 3, < 5)
116
+ ruby-progressbar (1.13.0)
117
+ traces (0.18.2)
118
+ tsort (0.2.0)
119
+
120
+ PLATFORMS
121
+ arm64-darwin-24
122
+ ruby
123
+
124
+ DEPENDENCIES
125
+ async-websocket
126
+ bundler
127
+ grpc
128
+ itsi-scheduler!
129
+ itsi-server!
130
+ jwt
131
+ minitest (~> 5.16)
132
+ minitest-reporters
133
+ net_http_unix
134
+ rack
135
+ rackup
136
+ rake (~> 13.0)
137
+ rake-compiler
138
+ rb_sys (~> 0.9.91)
139
+ redis
140
+ ruby-lsp
141
+
142
+ CHECKSUMS
143
+ ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
144
+ async (2.39.0) sha256=df18730073f2bbb45788077dfa20cb365ecc1b9453969f44de6796b5191a00aa
145
+ async-http (0.95.1) sha256=0c3dd458c204c06d5c4b20b01bbec4794a1203db627fb2ce536e1799ec14786c
146
+ async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed
147
+ async-websocket (0.30.1) sha256=54bb8a8f184e4aa64434c7a78ecc55850a67a3d1dcd02e3ae787376e2c673936
148
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
149
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
150
+ builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
151
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
152
+ console (1.36.0) sha256=45599ea906cf80a73d8941f03abf873fe66a6a954e0bac5bc1c01e2cdc406f07
153
+ fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03
154
+ fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06
155
+ fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
156
+ google-protobuf (4.30.2) sha256=0f35168dbeeccf13d928acf6c128cfec17b9a826ae4505246a02c115f4ae16ed
157
+ googleapis-common-protos-types (1.19.0) sha256=aecb76ca5326f8bcc47ab083259bbc4971d07e87f56808af7e210669d9765694
158
+ grpc (1.78.0) sha256=7aaf47b6d7783fb0e7d40fc853d34e802d47aef7b1312862b6e719141b3527c9
159
+ io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338
160
+ io-event (1.16.2) sha256=9f9cb0a96ea5c3850a672606c65f27bc96d7621399ef6196acbfe2be0cd1279c
161
+ io-stream (0.13.1) sha256=570d7c4dfb0fbd767480b4a222048a2be6d9b78febc1ec68258d0e0a4cde20de
162
+ itsi-scheduler (0.2.27)
163
+ itsi-server (0.2.27)
164
+ json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
165
+ jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535
166
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
167
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
168
+ metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
169
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
170
+ minitest-reporters (1.8.0) sha256=8ce5280fb73ad3178ae525454df169b6f28c1b38b1d088ea91815d3a370ba384
171
+ net_http_unix (0.2.2) sha256=98aa0e1e7787f8383e8dd8ff60fc18062c2ddb2aadbc76e92cfb4f95d2c9d71d
172
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
173
+ protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
174
+ protocol-http (0.62.2) sha256=e1c1f2f56029c1af8c4e2b8a67d0d096c76620f3afd8d99d1dcd2f6b8ffa773b
175
+ protocol-http1 (0.39.0) sha256=e49b3f4cda6f5d94c76a323d2b7f6977cba3ebd082d2da437039594da77ad8eb
176
+ protocol-http2 (0.26.0) sha256=bac89cd78082b241ccd0cf7246f5160e4bb0c9c975fb4bf7deef5f88cc317486
177
+ protocol-rack (0.22.1) sha256=1185d245927ef9849a603700d6991ca353bc89724fbf98efa4a4333ed62a9fc3
178
+ protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1
179
+ protocol-websocket (0.21.1) sha256=34325e4325697f0956877e67784bcc838cfd51ebbf4f8e9e5201be292041ee61
180
+ rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
181
+ rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
182
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
183
+ rake-compiler (1.3.0) sha256=eec272ef6d4dad27b36f5cdcf5b9ee4df2193751f4082b095f981ebf9cdf4127
184
+ rake-compiler-dock (1.11.0) sha256=eab51f2cd533eb35cea6b624a75281f047123e70a64c58b607471bb49428f8c2
185
+ rb_sys (0.9.126) sha256=ba958e0b8b4b89eeae0b3d24b64c809eb2c37e0ab0773a49e9b1c2e22c95aef8
186
+ rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6
187
+ redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
188
+ redis-client (0.24.0) sha256=ee65ee39cb2c38608b734566167fd912384f3c1241f59075e22858f23a085dbb
189
+ ruby-lsp (0.26.7) sha256=60a1199fc7e329348d63a2479854f94435725d833eeabf3d539b790185cbf21f
190
+ ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
191
+ traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
192
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
193
+
194
+ BUNDLED WITH
195
+ 2.6.9
data/gems/server/Rakefile CHANGED
@@ -29,6 +29,16 @@ SMOKE_TEST_GLOBS = %w[
29
29
  test/middleware/string_rewrite.rb
30
30
  ].freeze
31
31
 
32
+ def native_build_tasks_requested?
33
+ requested = Rake.application.top_level_tasks
34
+ return true if requested.empty?
35
+
36
+ requested.any? do |task_name|
37
+ task_name == "default" ||
38
+ task_name.start_with?("build", "compile", "cross", "clobber")
39
+ end
40
+ end
41
+
32
42
  def configure_test_task(task_name, test_globs)
33
43
  Minitest::TestTask.create(task_name) do |t|
34
44
  t.libs << "test"
@@ -41,17 +51,20 @@ end
41
51
 
42
52
  configure_test_task(:test, ["test/**/*.rb"])
43
53
  configure_test_task("test:smoke", SMOKE_TEST_GLOBS)
54
+ configure_test_task("test:acme", ["test/acme/**/*.rb"])
44
55
 
45
56
  task "test:full" => :test
46
57
 
47
- require "rb_sys/extensiontask"
58
+ if native_build_tasks_requested?
59
+ require "rb_sys/extensiontask"
48
60
 
49
- task build: :compile
61
+ task build: :compile
50
62
 
51
- GEMSPEC = Gem::Specification.load("itsi-server.gemspec")
63
+ GEMSPEC = Gem::Specification.load("itsi-server.gemspec")
52
64
 
53
- RbSys::ExtensionTask.new("itsi-server", GEMSPEC) do |ext|
54
- ext.lib_dir = "lib/itsi/server"
65
+ RbSys::ExtensionTask.new("itsi-server", GEMSPEC) do |ext|
66
+ ext.lib_dir = "lib/itsi/server"
67
+ end
55
68
  end
56
69
 
57
70
  task default: %i[compile test rubocop]
@@ -145,6 +145,16 @@ module Itsi
145
145
  end
146
146
  end
147
147
 
148
+ def partial_hijack
149
+ UNIXSocket.pair.yield_self do |(server_sock, app_sock)|
150
+ server_sock.autoclose = false
151
+ response.partial_hijack(server_sock.fileno)
152
+ server_sock.sync = true
153
+ app_sock.sync = true
154
+ app_sock
155
+ end
156
+ end
157
+
148
158
  # Rack expects env["rack.hijack"] to respond to #call.
149
159
  def call
150
160
  hijack
@@ -54,12 +54,40 @@ Let's Encrypt enforces strict rate limits on production certificate generation.
54
54
  {{< /callout >}}
55
55
 
56
56
 
57
- {{< callout type="warn" >}}
58
- Currently only the TLS-ALPN-01 challenge type is supported for automated certificates.
59
- The HTTP-01 challenge is not *yet* supported. This means that, for e.g. if your server is sitting behind a CDN or reverse proxy that performs HTTPS termination, you will not be able to rely on the *automated* certificate generation for fully automated, verified e2e encryption.
60
-
61
- Instead you may wish to use:
62
- * [Self-signed](#development--self-signed) certificates
63
- * [Manually](#existing-certificates) install certificates
64
- * Use HTTP between the CDN and the server
65
- {{< /callout >}}
57
+ Itsi supports both ACME challenge types that matter for common deployments:
58
+
59
+ * `TLS-ALPN-01` is used when the certificate authority can reach Itsi directly on the HTTPS listener.
60
+ * `HTTP-01` can be used when you also expose a reachable HTTP listener for the same hostname. In real Let's Encrypt deployments this typically means port `80` must reach Itsi for `/.well-known/acme-challenge/*`.
61
+
62
+ This means setups behind a CDN, WAF, or TLS-terminating proxy can still use automated certificates, provided plain HTTP validation traffic is forwarded to Itsi.
63
+
64
+ E.g. a production configuration that allows HTTP-01 fallback might look like this:
65
+
66
+ ```ruby {filename=Itsi.rb}
67
+ bind "http://0.0.0.0:80"
68
+ bind "https://0.0.0.0:443?cert=acme&domains=example.com&acme_email=you@example.com"
69
+ ```
70
+
71
+ ## Dynamic Domain Registration
72
+ You can add or remove ACME-managed domains while Itsi is already running.
73
+
74
+ This is useful when hostnames are discovered dynamically by your Ruby application, or when you want to defer certificate issuance until a tenant, customer, or site is activated.
75
+
76
+ Runtime APIs:
77
+
78
+ * `Itsi::Server.tls_bindings`
79
+ * `Itsi::Server.tls_domains(listener_id = nil)`
80
+ * `Itsi::Server.tls_domain_statuses(listener_id = nil)`
81
+ * `Itsi::Server.register_tls_domain(domain, listener_id = nil)`
82
+ * `Itsi::Server.unregister_tls_domain(domain, listener_id = nil)`
83
+
84
+ Example:
85
+
86
+ ```ruby
87
+ Itsi::Server.register_tls_domain("customer-a.example.com")
88
+
89
+ status = Itsi::Server.tls_domain_statuses.find { |entry| entry["domain"] == "customer-a.example.com" }
90
+ puts status
91
+ ```
92
+
93
+ When using dynamic issuance with HTTP-01, the same requirement still applies: the domain being issued must be able to reach an Itsi-managed HTTP listener for the ACME challenge path.
@@ -7,6 +7,8 @@ This allows Itsi to process a very large number of IO heavy requests concurrentl
7
7
 
8
8
  Enabling Fiber Scheduler mode can drastically improve application performance if you perform large amounts of blocking IO operations.
9
9
 
10
+ Itsi's bundled scheduler is intended to be practical for real Ruby applications, not just toy socket examples. It integrates with the scheduler hooks used by modern Rubies for socket I/O, DNS lookups, sleeps and timeouts, and process waiting.
11
+
10
12
 
11
13
  ## Configuration File
12
14
  ```ruby {filename="Itsi.rb"}
@@ -37,6 +37,10 @@ fiber_scheduler nil
37
37
  # bind "https://itsi.fyi?cert=acme&acme_email=admin@itsi.fyi"
38
38
  # You can generate certificates for multiple domains at once, by passing a comma-separated list of domains
39
39
  # bind "https://0.0.0.0?domains=foo.itsi.fyi,bar.itsi.fyi&cert=acme&acme_email=admin@itsi.fyi"
40
+ # If HTTPS on 443 is not directly reachable, you can also expose an HTTP listener and
41
+ # Let's Encrypt will be able to validate using HTTP-01 instead.
42
+ # bind "http://0.0.0.0:80"
43
+ # bind "https://0.0.0.0:443?domains=foo.itsi.fyi&cert=acme&acme_email=admin@itsi.fyi"
40
44
  #
41
45
  # If you already have a certificate you can specify it using the cert and key parameters
42
46
  # bind "https://itsi.fyi?cert=/path/to/cert.pem&key=/path/to/key.pem"
@@ -1,6 +1,45 @@
1
1
  module Itsi
2
2
  class Server
3
3
  module RackInterface
4
+ class PartialHijackStream
5
+ def initialize(response)
6
+ @response = response
7
+ end
8
+
9
+ def write(chunk)
10
+ @response.write(chunk.to_s)
11
+ end
12
+
13
+ def read(*)
14
+ nil
15
+ end
16
+
17
+ def <<(chunk)
18
+ write(chunk)
19
+ self
20
+ end
21
+
22
+ def flush
23
+ self
24
+ end
25
+
26
+ def close_write
27
+ @response.close_write
28
+ end
29
+
30
+ def close_read
31
+ true
32
+ end
33
+
34
+ def close
35
+ @response.close_write
36
+ end
37
+
38
+ def closed?
39
+ @response.closed?
40
+ end
41
+ end
42
+
4
43
  # Builds a handler proc that is compatible with Rack applications.
5
44
  def self.for(app)
6
45
  require "rack"
@@ -38,7 +77,8 @@ module Itsi
38
77
  response.status = status
39
78
 
40
79
  # 2. Set Headers
41
- body_streamer = streaming_body?(body) ? body : headers.delete("rack.hijack")
80
+ hijack_callback = headers.delete("rack.hijack")
81
+ body_streamer = streaming_body?(body) ? body : hijack_callback
42
82
 
43
83
  response.reserve_headers(headers.size)
44
84
 
@@ -57,10 +97,14 @@ module Itsi
57
97
  # the server will begin to stream it to the client.
58
98
 
59
99
 
60
- if body_streamer
100
+ if hijack_callback
101
+ stream = status == 101 ? request.partial_hijack : PartialHijackStream.new(response)
102
+ body_streamer.call(stream)
103
+ elsif body_streamer
61
104
  # If we're partially hijacked or returned a streaming body,
62
105
  # stream this response.
63
- body_streamer.call(response)
106
+ stream = status == 101 ? request.partial_hijack : response
107
+ body_streamer.call(stream)
64
108
 
65
109
  elsif body.is_a?(Array)
66
110
  if body.length == 1
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Itsi
4
4
  class Server
5
- VERSION = "0.2.26"
5
+ VERSION = "0.2.27"
6
6
  end
7
7
  end
@@ -32,6 +32,30 @@ module Itsi
32
32
  @running && !@running.empty?
33
33
  end
34
34
 
35
+ def current_server
36
+ @running&.last || raise("No running Itsi::Server instance")
37
+ end
38
+
39
+ def tls_bindings
40
+ current_server.tls_bindings
41
+ end
42
+
43
+ def tls_domains(listener_id = nil)
44
+ current_server.tls_domains(listener_id)
45
+ end
46
+
47
+ def tls_domain_statuses(listener_id = nil)
48
+ current_server.tls_domain_statuses(listener_id)
49
+ end
50
+
51
+ def register_tls_domain(domain, listener_id = nil)
52
+ current_server.register_tls_domain(domain, listener_id)
53
+ end
54
+
55
+ def unregister_tls_domain(domain, listener_id = nil)
56
+ current_server.unregister_tls_domain(domain, listener_id)
57
+ end
58
+
35
59
  def start_in_background_thread(cli_params = {}, &blk)
36
60
  @background_threads ||= []
37
61
  server, background_thread = start(cli_params, background: true, &blk)
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "helpers/test_helper"
4
+ require "helpers/local_acme"
5
+ require "socket"
6
+ require "timeout"
7
+
8
+ class TestLocalAcmeChallenges < Minitest::Test
9
+ APP = proc { |_env| [200, { "content-type" => "text/plain" }, ["acme-ok"]] }
10
+
11
+ class << self
12
+ def local_acme
13
+ raise Minitest::Skip, "Go is required for local ACME tests" unless LocalAcmeAuthority.available?
14
+
15
+ return @local_acme if @local_acme
16
+
17
+ @local_acme = LocalAcmeAuthority.new
18
+ @local_acme.start
19
+ @previous_env = {}
20
+ @local_acme.env.each do |key, value|
21
+ @previous_env[key] = ENV[key]
22
+ ENV[key] = value
23
+ end
24
+ @local_acme
25
+ end
26
+
27
+ def shutdown
28
+ return unless @local_acme
29
+
30
+ @previous_env&.each do |key, value|
31
+ value.nil? ? ENV.delete(key) : ENV[key] = value
32
+ end
33
+ @local_acme.stop
34
+ end
35
+ end
36
+
37
+ def test_tls_alpn01_issuance_without_http_listener
38
+ domain = "alpn.itsi.test"
39
+ local_acme = self.class.local_acme
40
+ https_bind = "https://0.0.0.0:#{local_acme.tls_port}?cert=acme&domains=#{domain}&acme_email=test@example.com"
41
+
42
+ server(app: APP, bind: https_bind) do
43
+ wait_for_https_response(local_acme.tls_port, domain)
44
+
45
+ certificate = peer_certificate(local_acme.tls_port, domain)
46
+ assert_certificate_domain(certificate, domain)
47
+
48
+ response = https_get(local_acme.tls_port, "/", domain)
49
+ assert_equal "200", response.code
50
+ assert_equal "acme-ok", response.body
51
+ end
52
+ end
53
+
54
+ def test_http01_issuance_when_tls_validation_port_is_unavailable
55
+ domain = "http01.itsi.test"
56
+ local_acme = self.class.local_acme
57
+ app_port = free_tcp_port
58
+ https_bind = "https://0.0.0.0:#{app_port}?cert=acme&domains=#{domain}&acme_email=test@example.com"
59
+ http_bind = "http://0.0.0.0:#{local_acme.http_port}"
60
+
61
+ server(app: APP, bind: https_bind, binds: [https_bind, http_bind]) do
62
+ wait_for_https_response(app_port, domain)
63
+
64
+ certificate = peer_certificate(app_port, domain)
65
+ assert_certificate_domain(certificate, domain)
66
+
67
+ response = https_get(app_port, "/", domain)
68
+ assert_equal "200", response.code
69
+ assert_equal "acme-ok", response.body
70
+ end
71
+ end
72
+
73
+ def test_dynamic_http01_issuance_with_runtime_domain_registration
74
+ domain = "runtime-http01.itsi.test"
75
+ local_acme = self.class.local_acme
76
+ app_port = free_tcp_port
77
+ https_bind = "https://0.0.0.0:#{app_port}?cert=acme&acme_email=test@example.com"
78
+ http_bind = "http://0.0.0.0:#{local_acme.http_port}"
79
+
80
+ server(app: APP, bind: https_bind, binds: [https_bind, http_bind]) do
81
+ assert_equal [], Itsi::Server.tls_domains
82
+ assert_equal ["tcp://0.0.0.0:#{app_port}"], Itsi::Server.tls_bindings
83
+
84
+ Itsi::Server.register_tls_domain(domain)
85
+ wait_until { Itsi::Server.tls_domains.include?(domain) }
86
+ wait_for_https_response(app_port, domain)
87
+
88
+ statuses = Itsi::Server.tls_domain_statuses
89
+ active = statuses.find { |status| status["domain"] == domain }
90
+ refute_nil active
91
+ assert_equal "active", active["status"]
92
+
93
+ certificate = peer_certificate(app_port, domain)
94
+ assert_certificate_domain(certificate, domain)
95
+
96
+ Itsi::Server.unregister_tls_domain(domain)
97
+ wait_until { !Itsi::Server.tls_domains.include?(domain) }
98
+ end
99
+ end
100
+
101
+ def test_dynamic_tls_alpn01_issuance_with_runtime_domain_registration
102
+ domain = "runtime-alpn.itsi.test"
103
+ local_acme = self.class.local_acme
104
+ https_bind = "https://0.0.0.0:#{local_acme.tls_port}?cert=acme&acme_email=test@example.com"
105
+
106
+ server(app: APP, bind: https_bind) do
107
+ assert_equal [], Itsi::Server.tls_domains
108
+
109
+ Itsi::Server.register_tls_domain(domain)
110
+ wait_until { Itsi::Server.tls_domains.include?(domain) }
111
+ wait_for_https_response(local_acme.tls_port, domain)
112
+
113
+ statuses = Itsi::Server.tls_domain_statuses
114
+ active = statuses.find { |status| status["domain"] == domain }
115
+ refute_nil active
116
+ assert_equal "active", active["status"]
117
+
118
+ certificate = peer_certificate(local_acme.tls_port, domain)
119
+ assert_certificate_domain(certificate, domain)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def free_tcp_port
126
+ server = TCPServer.new("127.0.0.1", 0)
127
+ port = server.addr[1]
128
+ server.close
129
+ port
130
+ end
131
+
132
+ def wait_for_https_response(port, host = "127.0.0.1")
133
+ Timeout.timeout(20) do
134
+ loop do
135
+ begin
136
+ response = https_get(port, "/", host)
137
+ return response if response.code == "200"
138
+ rescue StandardError
139
+ sleep 0.1
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ def wait_until(timeout: 10)
146
+ Timeout.timeout(timeout) do
147
+ loop do
148
+ return if yield
149
+
150
+ sleep 0.05
151
+ end
152
+ end
153
+ end
154
+
155
+ def https_get(port, path, host = "127.0.0.1")
156
+ Net::HTTP.start(
157
+ "127.0.0.1",
158
+ port,
159
+ use_ssl: true,
160
+ verify_mode: OpenSSL::SSL::VERIFY_NONE,
161
+ open_timeout: 1,
162
+ read_timeout: 1
163
+ ) do |http|
164
+ request = Net::HTTP::Get.new(path)
165
+ request["Host"] = host
166
+ http.request(request)
167
+ end
168
+ end
169
+
170
+ def peer_certificate(port, hostname)
171
+ tcp_socket = TCPSocket.new("127.0.0.1", port)
172
+ ssl_context = OpenSSL::SSL::SSLContext.new
173
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
174
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
175
+ ssl_socket.hostname = hostname if ssl_socket.respond_to?(:hostname=)
176
+ ssl_socket.connect
177
+ ssl_socket.peer_cert
178
+ ensure
179
+ ssl_socket&.close
180
+ tcp_socket&.close
181
+ end
182
+
183
+ def assert_certificate_domain(certificate, domain)
184
+ alt_names = certificate.extensions
185
+ .select { |extension| extension.oid == "subjectAltName" }
186
+ .flat_map { |extension| extension.value.split(/,\s*/) }
187
+
188
+ assert_includes alt_names, "DNS:#{domain}"
189
+ end
190
+ end