itsi 0.2.26 → 0.2.27.rc1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +7 -3
  3. data/Rakefile +24 -3
  4. data/crates/itsi_acme/Cargo.toml +2 -1
  5. data/crates/itsi_acme/src/acceptor.rs +1 -1
  6. data/crates/itsi_acme/src/acme.rs +31 -3
  7. data/crates/itsi_acme/src/http_challenge.rs +81 -0
  8. data/crates/itsi_acme/src/https_helper.rs +3 -1
  9. data/crates/itsi_acme/src/jose.rs +6 -2
  10. data/crates/itsi_acme/src/lib.rs +2 -0
  11. data/crates/itsi_acme/src/resolver.rs +27 -4
  12. data/crates/itsi_acme/src/state.rs +183 -22
  13. data/crates/itsi_scheduler/Cargo.toml +1 -1
  14. data/crates/itsi_scheduler/src/itsi_scheduler.rs +115 -64
  15. data/crates/itsi_scheduler/src/lib.rs +2 -1
  16. data/crates/itsi_server/Cargo.toml +2 -1
  17. data/crates/itsi_server/src/lib.rs +15 -0
  18. data/crates/itsi_server/src/ruby_types/itsi_http_request.rs +9 -0
  19. data/crates/itsi_server/src/ruby_types/itsi_http_response.rs +95 -0
  20. data/crates/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +22 -1
  21. data/crates/itsi_server/src/ruby_types/itsi_server.rs +100 -0
  22. data/crates/itsi_server/src/server/binds/listener.rs +9 -24
  23. data/crates/itsi_server/src/server/binds/tls.rs +372 -67
  24. data/crates/itsi_server/src/services/itsi_http_service.rs +46 -2
  25. data/gems/scheduler/Cargo.lock +4011 -527
  26. data/gems/scheduler/Gemfile +8 -2
  27. data/gems/scheduler/Gemfile.lock +107 -0
  28. data/gems/scheduler/Rakefile +33 -9
  29. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  30. data/gems/scheduler/lib/itsi/scheduler.rb +121 -6
  31. data/gems/scheduler/test/helpers/test_helper.rb +2 -0
  32. data/gems/scheduler/test/test_address_resolve.rb +8 -2
  33. data/gems/scheduler/test/test_itsi_scheduler.rb +80 -0
  34. data/gems/scheduler/test/test_timeout_after.rb +102 -0
  35. data/gems/server/Cargo.lock +30 -1
  36. data/gems/server/Gemfile +2 -0
  37. data/gems/server/Gemfile.lock +123 -0
  38. data/gems/server/Rakefile +18 -5
  39. data/gems/server/lib/itsi/http_request.rb +10 -0
  40. data/gems/server/lib/itsi/server/rack_interface.rb +45 -2
  41. data/gems/server/lib/itsi/server/version.rb +1 -1
  42. data/gems/server/lib/itsi/server.rb +24 -0
  43. data/gems/server/test/acme/local_acme_challenges.rb +190 -0
  44. data/gems/server/test/helpers/local_acme.rb +218 -0
  45. data/gems/server/test/helpers/test_helper.rb +7 -9
  46. data/gems/server/test/middleware/endpoint.rb +9 -6
  47. data/gems/server/test/rack/test_rack_server.rb +79 -0
  48. data/lib/itsi/version.rb +1 -1
  49. metadata +12 -6
@@ -0,0 +1,123 @@
1
+ PATH
2
+ remote: ../scheduler
3
+ specs:
4
+ itsi-scheduler (0.2.27.rc1)
5
+ rb_sys (~> 0.9.91)
6
+
7
+ PATH
8
+ remote: .
9
+ specs:
10
+ itsi-server (0.2.27.rc1)
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
+ base64 (0.3.0)
21
+ bigdecimal (4.1.2)
22
+ builder (3.3.0)
23
+ connection_pool (3.0.2)
24
+ google-protobuf (4.30.2)
25
+ bigdecimal
26
+ rake (>= 13)
27
+ googleapis-common-protos-types (1.19.0)
28
+ google-protobuf (>= 3.18, < 5.a)
29
+ grpc (1.78.0)
30
+ google-protobuf (>= 3.25, < 5.0)
31
+ googleapis-common-protos-types (~> 1.0)
32
+ json (2.19.4)
33
+ jwt (3.1.2)
34
+ base64
35
+ language_server-protocol (3.17.0.5)
36
+ logger (1.7.0)
37
+ minitest (5.27.0)
38
+ minitest-reporters (1.8.0)
39
+ ansi
40
+ builder
41
+ minitest (>= 5.0, < 7)
42
+ ruby-progressbar
43
+ net_http_unix (0.2.2)
44
+ prism (1.9.0)
45
+ rack (3.2.6)
46
+ rackup (2.3.1)
47
+ rack (>= 3)
48
+ rake (13.3.1)
49
+ rake-compiler (1.3.0)
50
+ rake
51
+ rake-compiler-dock (1.11.0)
52
+ rb_sys (0.9.126)
53
+ json (>= 2)
54
+ rake-compiler-dock (= 1.11.0)
55
+ rbs (3.10.3)
56
+ logger
57
+ tsort
58
+ redis (5.4.1)
59
+ redis-client (>= 0.22.0)
60
+ redis-client (0.24.0)
61
+ connection_pool
62
+ ruby-lsp (0.26.7)
63
+ language_server-protocol (~> 3.17.0)
64
+ prism (>= 1.2, < 2.0)
65
+ rbs (>= 3, < 5)
66
+ ruby-progressbar (1.13.0)
67
+ tsort (0.2.0)
68
+
69
+ PLATFORMS
70
+ arm64-darwin-24
71
+ ruby
72
+
73
+ DEPENDENCIES
74
+ bundler
75
+ grpc
76
+ itsi-scheduler!
77
+ itsi-server!
78
+ jwt
79
+ minitest (~> 5.16)
80
+ minitest-reporters
81
+ net_http_unix
82
+ rack
83
+ rackup
84
+ rake (~> 13.0)
85
+ rake-compiler
86
+ rb_sys (~> 0.9.91)
87
+ redis
88
+ ruby-lsp
89
+
90
+ CHECKSUMS
91
+ ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
92
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
93
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
94
+ builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
95
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
96
+ google-protobuf (4.30.2) sha256=0f35168dbeeccf13d928acf6c128cfec17b9a826ae4505246a02c115f4ae16ed
97
+ googleapis-common-protos-types (1.19.0) sha256=aecb76ca5326f8bcc47ab083259bbc4971d07e87f56808af7e210669d9765694
98
+ grpc (1.78.0) sha256=7aaf47b6d7783fb0e7d40fc853d34e802d47aef7b1312862b6e719141b3527c9
99
+ itsi-scheduler (0.2.27.rc1)
100
+ itsi-server (0.2.27.rc1)
101
+ json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
102
+ jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535
103
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
104
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
105
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
106
+ minitest-reporters (1.8.0) sha256=8ce5280fb73ad3178ae525454df169b6f28c1b38b1d088ea91815d3a370ba384
107
+ net_http_unix (0.2.2) sha256=98aa0e1e7787f8383e8dd8ff60fc18062c2ddb2aadbc76e92cfb4f95d2c9d71d
108
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
109
+ rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
110
+ rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
111
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
112
+ rake-compiler (1.3.0) sha256=eec272ef6d4dad27b36f5cdcf5b9ee4df2193751f4082b095f981ebf9cdf4127
113
+ rake-compiler-dock (1.11.0) sha256=eab51f2cd533eb35cea6b624a75281f047123e70a64c58b607471bb49428f8c2
114
+ rb_sys (0.9.126) sha256=ba958e0b8b4b89eeae0b3d24b64c809eb2c37e0ab0773a49e9b1c2e22c95aef8
115
+ rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6
116
+ redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
117
+ redis-client (0.24.0) sha256=ee65ee39cb2c38608b734566167fd912384f3c1241f59075e22858f23a085dbb
118
+ ruby-lsp (0.26.7) sha256=60a1199fc7e329348d63a2479854f94435725d833eeabf3d539b790185cbf21f
119
+ ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
120
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
121
+
122
+ BUNDLED WITH
123
+ 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
@@ -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,7 +97,10 @@ 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
106
  body_streamer.call(response)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Itsi
4
4
  class Server
5
- VERSION = "0.2.26"
5
+ VERSION = "0.2.27.rc1"
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
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "rbconfig"
8
+ require "shellwords"
9
+ require "timeout"
10
+ require "tmpdir"
11
+
12
+ class LocalAcmeAuthority
13
+ PEBBLE_VERSION = "v2.10.1"
14
+
15
+ attr_reader :directory_url, :cache_dir, :http_port, :tls_port
16
+
17
+ def self.available?
18
+ system("go version > /dev/null 2>&1")
19
+ end
20
+
21
+ def initialize
22
+ @workspace = Dir.mktmpdir("itsi-local-acme-")
23
+ @bin_dir = File.join(@workspace, "bin")
24
+ @cache_dir = File.join(@workspace, "cache")
25
+ FileUtils.mkdir_p(@bin_dir)
26
+ FileUtils.mkdir_p(@cache_dir)
27
+ @pids = []
28
+ end
29
+
30
+ def start
31
+ install_binaries
32
+ resolve_module_paths
33
+
34
+ @acme_port = free_port
35
+ @management_port = free_port
36
+ @dns_port = free_port
37
+ @dns_management_port = free_port
38
+ @http_port = free_port
39
+ @tls_port = free_port
40
+
41
+ write_pebble_config
42
+ start_challtestsrv
43
+ start_pebble
44
+
45
+ @directory_url = "https://localhost:#{@acme_port}/dir"
46
+ wait_for_https(@directory_url, @pebble_api_ca_path)
47
+ end
48
+
49
+ def stop
50
+ @pids.reverse_each do |pid|
51
+ begin
52
+ Process.kill("TERM", pid)
53
+ rescue Errno::ESRCH
54
+ next
55
+ end
56
+
57
+ begin
58
+ Timeout.timeout(5) { Process.wait(pid) }
59
+ rescue Timeout::Error
60
+ begin
61
+ Process.kill("KILL", pid)
62
+ rescue Errno::ESRCH
63
+ nil
64
+ end
65
+ begin
66
+ Process.wait(pid)
67
+ rescue Errno::ECHILD
68
+ nil
69
+ end
70
+ rescue Errno::ECHILD
71
+ nil
72
+ end
73
+ end
74
+ FileUtils.remove_entry(@workspace) if File.exist?(@workspace)
75
+ end
76
+
77
+ def env
78
+ {
79
+ "ITSI_ACME_DIRECTORY_URL" => @directory_url,
80
+ "ITSI_ACME_CA_PEM_PATH" => @pebble_api_ca_path,
81
+ "ITSI_ACME_CACHE_DIR" => @cache_dir
82
+ }
83
+ end
84
+
85
+ private
86
+
87
+ def install_binaries
88
+ install_go_binary("github.com/letsencrypt/pebble/v2/cmd/pebble", "pebble")
89
+ install_go_binary("github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv", "pebble-challtestsrv")
90
+ end
91
+
92
+ def install_go_binary(package_name, binary_name)
93
+ destination = File.join(@bin_dir, binary_name)
94
+ return if File.exist?(destination)
95
+
96
+ system(
97
+ {
98
+ "GOBIN" => @bin_dir
99
+ },
100
+ "go", "install", "#{package_name}@#{PEBBLE_VERSION}",
101
+ exception: true
102
+ )
103
+ end
104
+
105
+ def resolve_module_paths
106
+ module_json = JSON.parse(`go mod download -json github.com/letsencrypt/pebble/v2@#{PEBBLE_VERSION}`)
107
+ @pebble_module_dir = module_json.fetch("Dir")
108
+ @pebble_api_ca_path = File.join(@pebble_module_dir, "test/certs/pebble.minica.pem")
109
+ @pebble_tls_cert_path = File.join(@pebble_module_dir, "test/certs/localhost/cert.pem")
110
+ @pebble_tls_key_path = File.join(@pebble_module_dir, "test/certs/localhost/key.pem")
111
+ end
112
+
113
+ def write_pebble_config
114
+ @pebble_config_path = File.join(@workspace, "pebble-config.json")
115
+ File.write(
116
+ @pebble_config_path,
117
+ JSON.pretty_generate(
118
+ {
119
+ pebble: {
120
+ listenAddress: "0.0.0.0:#{@acme_port}",
121
+ managementListenAddress: "0.0.0.0:#{@management_port}",
122
+ certificate: @pebble_tls_cert_path,
123
+ privateKey: @pebble_tls_key_path,
124
+ httpPort: @http_port,
125
+ tlsPort: @tls_port,
126
+ ocspResponderURL: "",
127
+ externalAccountBindingRequired: false,
128
+ domainBlocklist: [],
129
+ retryAfter: {
130
+ authz: 1,
131
+ order: 1
132
+ },
133
+ keyAlgorithm: "ecdsa"
134
+ }
135
+ }
136
+ )
137
+ )
138
+ end
139
+
140
+ def start_challtestsrv
141
+ spawn_process(
142
+ [
143
+ File.join(@bin_dir, "pebble-challtestsrv"),
144
+ "-dnsserver", "127.0.0.1:#{@dns_port}",
145
+ "-management", "127.0.0.1:#{@dns_management_port}",
146
+ "-http01", "",
147
+ "-https01", "",
148
+ "-tlsalpn01", "",
149
+ "-defaultIPv4", "127.0.0.1",
150
+ "-defaultIPv6", ""
151
+ ],
152
+ "challtestsrv"
153
+ )
154
+ end
155
+
156
+ def start_pebble
157
+ spawn_process(
158
+ [
159
+ File.join(@bin_dir, "pebble"),
160
+ "-config", @pebble_config_path,
161
+ "-dnsserver", "127.0.0.1:#{@dns_port}",
162
+ "-strict=false"
163
+ ],
164
+ "pebble",
165
+ {
166
+ "PEBBLE_VA_NOSLEEP" => "1",
167
+ "PEBBLE_WFE_NONCEREJECT" => "0",
168
+ "PEBBLE_AUTHZREUSE" => "0"
169
+ }
170
+ )
171
+ end
172
+
173
+ def spawn_process(command, name, env = {})
174
+ log_path = File.join(@workspace, "#{name}.log")
175
+ log = File.open(log_path, "w")
176
+ pid = Process.spawn(
177
+ env,
178
+ *command,
179
+ out: log,
180
+ err: log
181
+ )
182
+ @pids << pid
183
+ end
184
+
185
+ def free_port
186
+ server = TCPServer.new("127.0.0.1", 0)
187
+ port = server.addr[1]
188
+ server.close
189
+ port
190
+ end
191
+
192
+ def wait_for_https(url, ca_file)
193
+ uri = URI(url)
194
+ store = OpenSSL::X509::Store.new
195
+ store.add_file(ca_file)
196
+
197
+ Timeout.timeout(20) do
198
+ loop do
199
+ begin
200
+ Net::HTTP.start(
201
+ uri.host,
202
+ uri.port,
203
+ use_ssl: true,
204
+ cert_store: store,
205
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
206
+ open_timeout: 1,
207
+ read_timeout: 1
208
+ ) do |http|
209
+ response = http.get(uri.request_uri)
210
+ return if response.code.to_i < 500
211
+ end
212
+ rescue StandardError
213
+ sleep 0.1
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end