yamine 0.3.0

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.
@@ -0,0 +1,489 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+ require "uri"
6
+
7
+ module Yamine
8
+ # Host-header reverse proxy: HTTPS on 443 (or HTTP on 80 with --no-tls)
9
+ # routing to unix-socket or TCP backends from the RouteStore.
10
+ #
11
+ # Pure stdlib: TCPServer + threads. HTTP/1.1 framing is honored
12
+ # per-request so keep-alive connections get header rewriting
13
+ # (X-Forwarded-*) on every request, not just the first; Upgrade
14
+ # requests (ActionCable) fall back to raw byte piping. Chunked or
15
+ # unclassifiable responses are close-delimited (connection not
16
+ # reused). Deliberately HTTP/1.1 only for v1.
17
+ class Proxy
18
+ HOPS_HEADER = "x-yamine-hops"
19
+ HEALTH_HEADER = "x-yamine"
20
+ MAX_HOPS = 5
21
+ MAX_HEAD_BYTES = 64 * 1024
22
+ MAX_HOSTNAME_BYTES = 253
23
+ # Bounded concurrency: beyond this many simultaneous connections the
24
+ # proxy answers 503 instead of spawning threads without limit.
25
+ MAX_CONNECTIONS = Integer(ENV.fetch("YAMINE_MAX_CONNECTIONS", "200"))
26
+ # Route cache: routes.json is the source of truth, but re-reading
27
+ # and re-parsing it on every request is wasteful under HMR polling.
28
+ # Keyed on file mtime, so a freshly registered route is visible on
29
+ # the very next request (no TTL race for agents that boot then curl).
30
+
31
+ NotFound = Struct.new(:host, :routes)
32
+ BadGateway = Struct.new(:host, :detail)
33
+ LoopDetected = Struct.new(:host, :hops)
34
+
35
+ def initialize(store:, port: 443, tls: true, state_dir: nil, on_error: nil,
36
+ supervisor: nil, max_connections: MAX_CONNECTIONS, tlds: nil)
37
+ @store = store
38
+ @port = port
39
+ @tls = tls
40
+ @state_dir = state_dir || Certs.state_dir
41
+ @on_error = on_error || ->(msg) { warn msg }
42
+ @supervisor = supervisor
43
+ @max_connections = max_connections
44
+ @tlds = Array(tlds).flatten.compact.map(&:downcase)
45
+ @tlds = [Hostname::DEFAULT_TLD] if @tlds.empty?
46
+ @inflight = 0
47
+ @inflight_mutex = Mutex.new
48
+ @route_cache = nil
49
+ @route_cache_mtime = nil
50
+ @route_cache_mutex = Mutex.new
51
+ @running = false
52
+ end
53
+
54
+ attr_reader :port, :supervisor, :tlds
55
+
56
+ def tls?
57
+ @tls
58
+ end
59
+
60
+ def start_foreground
61
+ @running = true
62
+ servers = build_servers
63
+ servers.each { |s| s.listen(@port) }
64
+ @port = servers.first.addr[1]
65
+ Ownership.chown_state_dir(@store.dir)
66
+ ensure_system_ca_trust if Process.uid.zero?
67
+ trap("INT") { stop(servers) }
68
+ trap("TERM") do
69
+ @supervisor&.shutdown
70
+ stop(servers)
71
+ end
72
+ acceptors = servers.map do |server|
73
+ Thread.new do
74
+ while @running
75
+ begin
76
+ sock = server.accept
77
+ unless admit?
78
+ begin
79
+ sock.write("HTTP/1.1 503 Service Unavailable\r\n" \
80
+ "Content-Length: 0\r\nConnection: close\r\n\r\n")
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ sock.close rescue nil
85
+ next
86
+ end
87
+ Thread.new do
88
+ begin
89
+ handle(sock)
90
+ ensure
91
+ release
92
+ end
93
+ end
94
+ rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
95
+ break unless @running
96
+ end
97
+ end
98
+ end
99
+ end
100
+ acceptors.each(&:join)
101
+ end
102
+
103
+ def stop(servers = nil)
104
+ @running = false
105
+ Array(servers).each { |s| s.close rescue nil }
106
+ end
107
+
108
+ # Pure request-routing core, tested without sockets.
109
+ def route(authority, routes)
110
+ host = Hostname.strip_port(authority)
111
+ return nil if host.empty? || host.bytesize > MAX_HOSTNAME_BYTES
112
+
113
+ exact = routes.find { |r| r["hostname"] == host }
114
+ return exact if exact
115
+
116
+ wildcard = routes.find { |r| host.end_with?(".#{r["hostname"]}") }
117
+ wildcard
118
+ end
119
+
120
+ def check_hops(headers)
121
+ hops = headers[HOPS_HEADER].to_i
122
+ hops >= MAX_HOPS ? hops : nil
123
+ end
124
+
125
+ private
126
+
127
+ # A root proxy (launchd service or sudo daemon) is the one process
128
+ # that can trust the CA into the System keychain silently — no GUI
129
+ # popup. Idempotent: the state-dir marker makes repeat boots a no-op.
130
+ def ensure_system_ca_trust
131
+ return if Certs.trusted?(@state_dir)
132
+
133
+ result = Trust.trust(@state_dir)
134
+ @on_error.call("CA trust warning: #{result[:error]}") unless result[:trusted]
135
+ end
136
+
137
+ def admit?
138
+ @inflight_mutex.synchronize do
139
+ return false if @inflight >= @max_connections
140
+
141
+ @inflight += 1
142
+ true
143
+ end
144
+ end
145
+
146
+ def release
147
+ @inflight_mutex.synchronize { @inflight -= 1 if @inflight.positive? }
148
+ end
149
+
150
+ def cached_routes
151
+ @route_cache_mutex.synchronize do
152
+ mtime = routes_mtime
153
+ if @route_cache.nil? || mtime != @route_cache_mtime
154
+ @route_cache = @store.load_routes
155
+ @route_cache_mtime = mtime
156
+ end
157
+ @route_cache
158
+ end
159
+ end
160
+
161
+ def routes_mtime
162
+ File.mtime(@store.routes_path)
163
+ rescue SystemCallError
164
+ nil
165
+ end
166
+
167
+ # Both IPv4 and IPv6 loopback: *.localhost often resolves to ::1
168
+ # first, and binding v4-only then refuses with no helpful message.
169
+ def build_servers
170
+ [TCPServer.new("127.0.0.1", @port), TCPServer.new("::1", @port)].map do |server|
171
+ next server unless @tls
172
+
173
+ OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))
174
+ end
175
+ rescue SystemCallError
176
+ server = TCPServer.new("127.0.0.1", @port)
177
+ @tls ? [OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))] : [server]
178
+ end
179
+
180
+ # One connection = many requests (keep-alive). Each request head is
181
+ # re-parsed and rewritten; bodies are framed by Content-Length;
182
+ # responses with Content-Length allow the loop to continue.
183
+ def handle(sock)
184
+ buf = +""
185
+ loop do
186
+ head, buf = read_head(sock, buf)
187
+ break if head.nil? || head.empty?
188
+
189
+ method, target, headers = parse_head(head)
190
+ host = headers["host"].to_s
191
+
192
+ if upgrade_request?(headers)
193
+ handle_upgrade(sock, head, buf)
194
+ break
195
+ end
196
+
197
+ routes = cached_routes
198
+ entry = route(host, routes)
199
+ if entry.nil?
200
+ render_not_found(sock, host, routes)
201
+ break
202
+ end
203
+
204
+ if check_hops(headers)
205
+ render_loop(sock, Hostname.strip_port(host))
206
+ break
207
+ end
208
+
209
+ # Supervised managed apps may be stopped (idle/crashed/restarted):
210
+ # boot on request, then serve.
211
+ if @supervisor && entry["kind"] == "socket" && entry["spec"]
212
+ entry = @supervisor.ensure_running(entry)
213
+ unless entry
214
+ render_bad_gateway(sock)
215
+ break
216
+ end
217
+ end
218
+ @supervisor&.touch(entry["hostname"])
219
+
220
+ headers[HOPS_HEADER] = (headers[HOPS_HEADER].to_i + 1).to_s
221
+ set_forwarded(headers, sock, tls: @tls)
222
+
223
+ begin
224
+ backend = dial(entry)
225
+ rescue SystemCallError => e
226
+ @on_error.call("dial failed for #{host}: #{e.message}")
227
+ render_bad_gateway(sock)
228
+ break
229
+ end
230
+
231
+ keep = pipe_request(sock, backend, method, target, headers, buf)
232
+ unless keep == :keep_alive
233
+ backend.close rescue nil
234
+ break
235
+ end
236
+ backend.close rescue nil
237
+ end
238
+ rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
239
+ @on_error.call("Proxy error: #{e.message}")
240
+ render_bad_gateway(sock) rescue nil
241
+ ensure
242
+ sock.close rescue nil
243
+ end
244
+
245
+ # Forward one request (body framed by Content-Length), then relay
246
+ # the response. Returns :keep_alive when both sides want to reuse
247
+ # the connection and the response length was known.
248
+ def pipe_request(sock, backend, method, target, headers, buf)
249
+ backend.write(rebuild_head(method, target, headers))
250
+
251
+ body_len = request_body_length(headers)
252
+ if body_len == :chunked
253
+ # Unclassifiable request body: stream to EOF, close after.
254
+ backend.write(buf) unless buf.empty?
255
+ IO.copy_stream(sock, backend)
256
+ relay_response_close(backend, sock)
257
+ return :close
258
+ end
259
+
260
+ remaining = body_len
261
+ unless buf.empty?
262
+ from_buf = buf.byteslice(0, remaining)
263
+ backend.write(from_buf)
264
+ remaining -= from_buf.bytesize
265
+ buf = buf.byteslice(from_buf.bytesize..) || +""
266
+ end
267
+ if remaining > 0
268
+ IO.copy_stream(sock, backend, remaining)
269
+ end
270
+
271
+ rhead, rbuf = read_head(backend, +"")
272
+ if rhead.nil?
273
+ render_bad_gateway(sock)
274
+ return :close
275
+ end
276
+ _rm, _rt, rheaders = parse_head(rhead)
277
+ sock.write(rhead)
278
+ sock.write(rbuf) unless rbuf.empty?
279
+
280
+ rlen = content_length(rheaders)
281
+ if rlen.nil?
282
+ # No Content-Length: close-delimited response.
283
+ IO.copy_stream(backend, sock)
284
+ return :close
285
+ end
286
+ remaining = rlen - rbuf.bytesize
287
+ if remaining > 0
288
+ IO.copy_stream(backend, sock, remaining)
289
+ end
290
+
291
+ if keep_alive?(headers) && keep_alive?(rheaders)
292
+ # Any bytes beyond Content-Length on the backend are a second
293
+ # pipelined response on a connection we won't reuse — drop.
294
+ :keep_alive
295
+ else
296
+ :close
297
+ end
298
+ end
299
+
300
+ def relay_response_close(backend, sock)
301
+ rhead, rbuf = read_head(backend, +"")
302
+ return if rhead.nil?
303
+
304
+ sock.write(rhead)
305
+ sock.write(rbuf) unless rbuf.empty?
306
+ IO.copy_stream(backend, sock)
307
+ rescue IOError, SystemCallError
308
+ nil
309
+ end
310
+
311
+ def handle_upgrade(sock, head, buf)
312
+ routes = cached_routes
313
+ _method, _target, headers = parse_head(head)
314
+ entry = route(headers["host"].to_s, routes)
315
+ return unless entry
316
+
317
+ backend = dial(entry)
318
+ backend.write(head)
319
+ backend.write(buf) unless buf.empty?
320
+ pipe_both(sock, backend)
321
+ rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
322
+ @on_error.call("upgrade proxy error: #{e.message}")
323
+ ensure
324
+ backend&.close rescue nil
325
+ sock.close rescue nil
326
+ end
327
+
328
+ def upgrade_request?(headers)
329
+ headers["upgrade"] && headers["connection"].to_s.downcase.include?("upgrade")
330
+ end
331
+
332
+ def request_body_length(headers)
333
+ if headers["content-length"]
334
+ Integer(headers["content-length"])
335
+ elsif headers["transfer-encoding"].to_s.downcase.include?("chunked")
336
+ :chunked
337
+ else
338
+ 0
339
+ end
340
+ rescue ArgumentError
341
+ :chunked
342
+ end
343
+
344
+ def content_length(headers)
345
+ headers["content-length"] && Integer(headers["content-length"])
346
+ rescue ArgumentError
347
+ nil
348
+ end
349
+
350
+ def keep_alive?(headers)
351
+ return false if headers["connection"].to_s.downcase.include?("close")
352
+
353
+ true
354
+ end
355
+
356
+ # Read just the header block with readpartial (no stdio buffering,
357
+ # so nothing is stolen from the body stream). `buf` carries bytes
358
+ # read ahead (pipelined requests) across calls. Returns [head, buf].
359
+ def read_head(sock, buf)
360
+ loop do
361
+ if (idx = buf.index("\r\n\r\n"))
362
+ return [buf.byteslice(0, idx + 4), buf.byteslice(idx + 4..) || +""]
363
+ end
364
+ return [nil, buf] if buf.bytesize > MAX_HEAD_BYTES
365
+
366
+ chunk = sock.readpartial(16_384)
367
+ buf << chunk
368
+ end
369
+ rescue EOFError, IOError
370
+ [nil, +""]
371
+ end
372
+
373
+ def parse_head(head)
374
+ lines = head.split("\r\n")
375
+ method, target, _version = lines.first.to_s.split(" ", 3)
376
+ headers = {}
377
+ lines[1..].each do |line|
378
+ break if line.empty?
379
+
380
+ key, value = line.split(":", 2)
381
+ headers[key.strip.downcase] = value.strip if key && value
382
+ end
383
+ [method, target, headers]
384
+ end
385
+
386
+ # X-Forwarded-* trust boundary: the proxy only ever listens on
387
+ # loopback, so a client-supplied X-Forwarded-For is the local
388
+ # developer's own header, not an attacker-controlled spoof. Never
389
+ # bind this proxy to a non-loopback interface without rethinking
390
+ # this (and Host-based routing) first.
391
+ def set_forwarded(headers, sock, tls:)
392
+ addr = begin
393
+ sock.peeraddr[3]
394
+ rescue StandardError
395
+ "127.0.0.1"
396
+ end
397
+ headers["x-forwarded-for"] = [headers["x-forwarded-for"], addr].compact.join(", ")
398
+ headers["x-forwarded-proto"] = tls ? "https" : "http"
399
+ headers["x-forwarded-host"] ||= headers["host"].to_s
400
+ end
401
+
402
+ def dial(entry)
403
+ case entry["kind"]
404
+ when "socket"
405
+ UNIXSocket.new(entry["target"])
406
+ else
407
+ host, port = entry["target"].split(":", 2)
408
+ TCPSocket.new(host, port.to_i)
409
+ end
410
+ end
411
+
412
+ def rebuild_head(method, target, headers)
413
+ lines = ["#{method} #{target} HTTP/1.1"]
414
+ headers.each { |k, v| lines << "#{k}: #{v}" }
415
+ (lines.join("\r\n") + "\r\n\r\n")
416
+ end
417
+
418
+ # Raw bidirectional pump for upgrades; either side closing unblocks
419
+ # the other.
420
+ def pipe_both(client, backend)
421
+ t1 = Thread.new do
422
+ IO.copy_stream(client, backend)
423
+ rescue IOError, SystemCallError
424
+ nil
425
+ ensure
426
+ backend.close rescue nil
427
+ end
428
+ t2 = Thread.new do
429
+ IO.copy_stream(backend, client)
430
+ rescue IOError, SystemCallError
431
+ nil
432
+ ensure
433
+ client.close rescue nil
434
+ end
435
+ t1.join
436
+ t2.join
437
+ end
438
+
439
+ # DNS-rebinding boundary: the proxy binds loopback, so any website
440
+ # that tricks a browser into requesting 127.0.0.1 reaches us. A
441
+ # foreign Host (outside our TLDs) gets a bare 404 that names nothing —
442
+ # never the route list. Only requests under our own TLDs see the
443
+ # helpful "no app registered, here are your apps" page, where the
444
+ # requester is unambiguously the local developer.
445
+ def friendly_host?(host)
446
+ @tlds.any? { |tld| host == tld || host.end_with?(".#{tld}") }
447
+ end
448
+
449
+ def render_not_found(sock, host, routes)
450
+ bare = Hostname.strip_port(host)
451
+ unless friendly_host?(bare.downcase)
452
+ return respond(sock, 404, "<h1>Not Found</h1>")
453
+ end
454
+
455
+ items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
456
+ body = "<h1>No app registered for #{escape(bare)}</h1>" \
457
+ "<ul>#{items}</ul>"
458
+ respond(sock, 404, body)
459
+ end
460
+
461
+ def render_bad_gateway(sock)
462
+ respond(sock, 502, "<h1>Bad Gateway</h1><p>The target app is not responding.</p>")
463
+ rescue IOError, SystemCallError
464
+ nil
465
+ end
466
+
467
+ def render_loop(sock, host)
468
+ respond(sock, 508, "<h1>Loop Detected</h1><p>#{escape(host)} passed through " \
469
+ "yamine too many times. Check dev-server proxy config.</p>")
470
+ rescue IOError, SystemCallError
471
+ nil
472
+ end
473
+
474
+ def respond(sock, status, body)
475
+ message = { 404 => "Not Found", 502 => "Bad Gateway", 508 => "Loop Detected" }[status]
476
+ sock.write("HTTP/1.1 #{status} #{message}\r\n" \
477
+ "Content-Type: text/html\r\n" \
478
+ "#{HEALTH_HEADER}: 1\r\n" \
479
+ "Content-Length: #{body.bytesize}\r\n" \
480
+ "Connection: close\r\n\r\n#{body}")
481
+ rescue IOError, SystemCallError
482
+ nil
483
+ end
484
+
485
+ def escape(text)
486
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
487
+ end
488
+ end
489
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "openssl"
5
+ require "socket"
6
+ require "timeout"
7
+
8
+ module Yamine
9
+ # Proxy daemon lifecycle: pid/port/tls files, liveness verification,
10
+ # spawn (with sudo for privileged ports), stop.
11
+ module ProxyControl
12
+ DEFAULT_TLS_PORT = 443
13
+ DEFAULT_PLAIN_PORT = 80
14
+ LOG_NAME = "proxy.log"
15
+
16
+ module_function
17
+
18
+ # Path to the yamine executable relative to this file
19
+ # (lib/ask/local -> gem root/bin). Used for daemon spawn and
20
+ # service install; both must resolve identically in dev checkouts
21
+ # and installed gems.
22
+ def bin_path
23
+ File.expand_path("../../../bin/yamine", __dir__)
24
+ end
25
+
26
+ def root?
27
+ Process.uid.zero?
28
+ end
29
+
30
+ def default_port(tls)
31
+ env = ENV["YAMINE_PORT"]
32
+ return env.to_i if env && env.to_i.between?(1, 65_535)
33
+
34
+ tls ? DEFAULT_TLS_PORT : DEFAULT_PLAIN_PORT
35
+ end
36
+
37
+ def proxy_tls(store)
38
+ marker = File.join(store.dir, "proxy.tls")
39
+ return false if ENV["YAMINE_HTTPS"] == "0"
40
+ return true if ENV["YAMINE_HTTPS"] == "1"
41
+ return false if File.file?(marker) && File.read(marker).strip == "0"
42
+
43
+ true
44
+ rescue SystemCallError
45
+ true
46
+ end
47
+
48
+ # Anything TCP-listening on the port (either loopback)?
49
+ def listening?(port)
50
+ ["127.0.0.1", "::1"].any? do |host|
51
+ begin
52
+ TCPSocket.new(host, port).close
53
+ true
54
+ rescue SystemCallError
55
+ false
56
+ end
57
+ end
58
+ end
59
+
60
+ # Is the thing on this port OUR proxy? Health requests hit an
61
+ # unregistered host; our proxy answers 404 with X-Yamine: 1.
62
+ # Probes both loopbacks: the proxy binds v4+IPv6, and on machines
63
+ # where only one family answers the check must still succeed.
64
+ # An explicit regression test pins this (health_test pinning
65
+ # ensure_proxy's "is that ours" logic against future proxy changes).
66
+ #
67
+ # Probe order matters: plain HTTP first (our proxy byte-peeks and
68
+ # answers plain HTTP even on the TLS port), TLS second. A TLS-first
69
+ # handshake against a foreign plain-HTTP server blocks in connect
70
+ # waiting for a ServerHello that never comes — and connect used to
71
+ # sit outside the timeout, hanging ensure_proxy for over a minute.
72
+ #
73
+ # Speed: if the plain probe gets ANY HTTP response without our
74
+ # header, the server is definitively foreign — no TLS retry. The
75
+ # slow TLS retry only happens when plain yielded zero bytes
76
+ # (connection error, EOF, or timeout against a silent server).
77
+ def ours?(port, tls:)
78
+ ["127.0.0.1", "::1"].any? do |host|
79
+ case probe_once(port, tls: false, host: host)
80
+ when :ours then true
81
+ when :foreign then false
82
+ else tls ? probe_once(port, tls: true, host: host) == :ours : false
83
+ end
84
+ end
85
+ end
86
+
87
+ # Three outcomes: :ours (our header present), :foreign (an HTTP
88
+ # response without it), :unknown (no response at all).
89
+ def probe_once(port, tls:, host:)
90
+ sock = nil
91
+ Timeout.timeout(5) do
92
+ sock = TCPSocket.new(host, port)
93
+ if tls
94
+ ctx = OpenSSL::SSL::SSLContext.new
95
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
96
+ sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
97
+ sock.connect
98
+ end
99
+ sock.write("GET / HTTP/1.1\r\nHost: yamine-health.invalid\r\nConnection: close\r\n\r\n")
100
+ head = +""
101
+ while (chunk = sock.readpartial(4096))
102
+ head << chunk
103
+ break if head.include?("\r\n\r\n")
104
+ end
105
+ return :unknown if head.empty?
106
+
107
+ return head.downcase.include?("x-yamine: 1") ? :ours : :foreign
108
+ end
109
+ rescue SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, IOError, EOFError
110
+ :unknown
111
+ ensure
112
+ begin
113
+ sock&.close
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+
119
+ def probe_ours(port, tls:, host:)
120
+ probe_once(port, tls: tls, host: host) == :ours
121
+ end
122
+
123
+ def pid_alive?(pid)
124
+ Process.kill(0, pid)
125
+ true
126
+ rescue SystemCallError
127
+ false
128
+ end
129
+
130
+ def read_pid(store)
131
+ return nil unless File.file?(store.pid_path)
132
+
133
+ pid = File.read(store.pid_path).strip.to_i
134
+ pid.positive? ? pid : nil
135
+ rescue SystemCallError, ArgumentError
136
+ nil
137
+ end
138
+
139
+ def write_pid(store, pid, port, tls)
140
+ store.ensure_dir
141
+ File.write(store.pid_path, "#{pid}\n")
142
+ File.write(store.port_path, "#{port}\n")
143
+ File.write(File.join(store.dir, "proxy.tls"), tls ? "1" : "0")
144
+ Ownership.fix(store.pid_path, store.port_path, File.join(store.dir, "proxy.tls"))
145
+ end
146
+
147
+ def clear_pid(store)
148
+ FileUtils.rm_f(store.pid_path)
149
+ FileUtils.rm_f(store.port_path)
150
+ end
151
+
152
+ def proxy_port(store)
153
+ return nil unless File.file?(store.port_path)
154
+
155
+ port = File.read(store.port_path).strip.to_i
156
+ port.positive? ? port : nil
157
+ rescue SystemCallError, ArgumentError
158
+ nil
159
+ end
160
+
161
+ # Start the proxy as a detached daemon. Sudo is used for privileged
162
+ # ports (portless auto-elevate pattern); the state dir is passed
163
+ # explicitly because sudo does not preserve the environment.
164
+ # Returns pid.
165
+ def spawn_daemon(store:, port:, tls:, sudo: false, tlds: nil)
166
+ store.ensure_dir
167
+ log_path = File.join(store.dir, LOG_NAME)
168
+ Log.rotate(log_path)
169
+ args = [RbConfig.ruby, bin_path,
170
+ "proxy", "start", "--foreground", "--port", port.to_s]
171
+ args << "--no-tls" unless tls
172
+ Array(tlds).each { |t| args.concat(["--tld", t]) }
173
+ state_arg = "YAMINE_STATE_DIR=#{store.dir}"
174
+ cmd = sudo ? ["sudo", "env", state_arg, *args] : [*args]
175
+ pid = spawn({ "YAMINE_STATE_DIR" => store.dir }, *cmd,
176
+ out: log_path, err: [:child, :out])
177
+ Process.detach(pid)
178
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
179
+ until ours?(port, tls: tls)
180
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
181
+ raise ProxyNotRunningError,
182
+ "Proxy did not start on port #{port}. " \
183
+ "Log (#{log_path}):\n#{log_tail(log_path)}"
184
+ end
185
+
186
+ sleep 0.25
187
+ end
188
+ write_pid(store, pid, port, tls)
189
+ pid
190
+ end
191
+
192
+ def log_tail(path, lines: 15)
193
+ return "(no log yet)" unless File.file?(path)
194
+
195
+ File.readlines(path).last(lines).join
196
+ rescue SystemCallError
197
+ "(unreadable log)"
198
+ end
199
+
200
+ def stop(store)
201
+ pid = read_pid(store)
202
+ port = proxy_port(store)
203
+ if pid.nil?
204
+ return :not_running unless port && listening?(port)
205
+
206
+ return :unknown_process
207
+ end
208
+ unless pid_alive?(pid)
209
+ clear_pid(store)
210
+ return :stale
211
+ end
212
+ begin
213
+ Process.kill("TERM", pid)
214
+ rescue SystemCallError
215
+ clear_pid(store)
216
+ return :stale
217
+ end
218
+ clear_pid(store)
219
+ :stopped
220
+ end
221
+ end
222
+ end