yamine 0.19.0 → 0.21.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -0
- data/README.md +58 -14
- data/lib/yamine/certs.rb +107 -7
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/system.rb +303 -83
- data/lib/yamine/cli/worktree.rb +6 -1
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/doctor.rb +99 -0
- data/lib/yamine/errors.rb +4 -0
- data/lib/yamine/hosts.rb +76 -8
- data/lib/yamine/privileged_payload.rb +419 -0
- data/lib/yamine/proxy.rb +166 -33
- data/lib/yamine/proxy_control.rb +76 -6
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
data/lib/yamine/proxy.rb
CHANGED
|
@@ -23,6 +23,20 @@ module Yamine
|
|
|
23
23
|
# Bounded concurrency: beyond this many simultaneous connections the
|
|
24
24
|
# proxy answers 503 instead of spawning threads without limit.
|
|
25
25
|
MAX_CONNECTIONS = Integer(ENV.fetch("YAMINE_MAX_CONNECTIONS", "200"))
|
|
26
|
+
# Idle bound: waiting for the NEXT byte on any client or backend
|
|
27
|
+
# socket gives up after this many seconds. The clock resets on every
|
|
28
|
+
# byte transferred, so it caps silence — never total duration. A
|
|
29
|
+
# slow trickle (SSE/NDJSON chat stream, big download) runs as long as
|
|
30
|
+
# bytes keep moving; a peer that goes quiet is dropped instead of
|
|
31
|
+
# pinning a thread. Without this, stale keep-alive sockets and
|
|
32
|
+
# stalled peers accumulate until the proxy stops answering.
|
|
33
|
+
# Named YAMINE_PROXY_IDLE_TIMEOUT (not YAMINE_IDLE_TIMEOUT — that one
|
|
34
|
+
# is the Supervisor's app-idle kill, a different clock entirely).
|
|
35
|
+
IDLE_TIMEOUT = Float(ENV.fetch("YAMINE_PROXY_IDLE_TIMEOUT", "60"))
|
|
36
|
+
# Raised when a peer is silent past the idle bound. An IOError so the
|
|
37
|
+
# existing rescue-and-close paths treat it like any dead peer.
|
|
38
|
+
IdleTimeout = Class.new(IOError)
|
|
39
|
+
CHUNK_BYTES = 16_384
|
|
26
40
|
# Route cache: routes.json is the source of truth, but re-reading
|
|
27
41
|
# and re-parsing it on every request is wasteful under HMR polling.
|
|
28
42
|
# Keyed on file mtime, so a freshly registered route is visible on
|
|
@@ -33,7 +47,7 @@ module Yamine
|
|
|
33
47
|
LoopDetected = Struct.new(:host, :hops)
|
|
34
48
|
|
|
35
49
|
def initialize(store:, port: 443, tls: true, state_dir: nil, on_error: nil,
|
|
36
|
-
supervisor: nil, max_connections: MAX_CONNECTIONS, tlds: nil)
|
|
50
|
+
supervisor: nil, max_connections: MAX_CONNECTIONS, idle_timeout: IDLE_TIMEOUT, tlds: nil)
|
|
37
51
|
@store = store
|
|
38
52
|
@port = port
|
|
39
53
|
@tls = tls
|
|
@@ -41,6 +55,7 @@ module Yamine
|
|
|
41
55
|
@on_error = on_error || ->(msg) { warn msg }
|
|
42
56
|
@supervisor = supervisor
|
|
43
57
|
@max_connections = max_connections
|
|
58
|
+
@idle_timeout = idle_timeout
|
|
44
59
|
@tlds = Array(tlds).flatten.compact.map(&:downcase)
|
|
45
60
|
@tlds = [Hostname::DEFAULT_TLD] if @tlds.empty?
|
|
46
61
|
@inflight = 0
|
|
@@ -92,7 +107,17 @@ module Yamine
|
|
|
92
107
|
end
|
|
93
108
|
end
|
|
94
109
|
rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
|
|
95
|
-
|
|
110
|
+
# A per-connection accept failure (bad TLS handshake,
|
|
111
|
+
# reset peer) is one dropped connection, not a reason to
|
|
112
|
+
# stop listening. Only shutdown breaks the loop.
|
|
113
|
+
next if @running
|
|
114
|
+
break
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
# An unexpected accept error must not silently kill the
|
|
117
|
+
# acceptor either: log it and keep serving while running.
|
|
118
|
+
@on_error.call("accept error: #{e.class}: #{e.message}")
|
|
119
|
+
next if @running
|
|
120
|
+
break
|
|
96
121
|
end
|
|
97
122
|
end
|
|
98
123
|
end
|
|
@@ -180,17 +205,29 @@ module Yamine
|
|
|
180
205
|
[TCPServer.new("127.0.0.1", @port), TCPServer.new("::1", @port)].map do |server|
|
|
181
206
|
next server unless @tls
|
|
182
207
|
|
|
183
|
-
OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))
|
|
208
|
+
with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))
|
|
184
209
|
end
|
|
185
210
|
rescue SystemCallError
|
|
186
211
|
server = TCPServer.new("127.0.0.1", @port)
|
|
187
|
-
@tls ? [OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))] : [server]
|
|
212
|
+
@tls ? [with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))] : [server]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The TLS handshake reads from the peer, so doing it inside
|
|
216
|
+
# SSLServer#accept would let a connect-and-send-nothing client pin an
|
|
217
|
+
# acceptor thread forever (and with both acceptors pinned the proxy
|
|
218
|
+
# stops answering). Defer it: accept returns after the TCP handshake
|
|
219
|
+
# and the per-connection thread finishes TLS under the idle bound in
|
|
220
|
+
# #handle. SNI/cert behavior is unchanged — only the timing moves.
|
|
221
|
+
def with_deferred_handshake(ssl_server)
|
|
222
|
+
ssl_server.start_immediately = false
|
|
223
|
+
ssl_server
|
|
188
224
|
end
|
|
189
225
|
|
|
190
226
|
# One connection = many requests (keep-alive). Each request head is
|
|
191
227
|
# re-parsed and rewritten; bodies are framed by Content-Length;
|
|
192
228
|
# responses with Content-Length allow the loop to continue.
|
|
193
229
|
def handle(sock)
|
|
230
|
+
tls_handshake(sock)
|
|
194
231
|
buf = +""
|
|
195
232
|
loop do
|
|
196
233
|
head, buf = read_head(sock, buf)
|
|
@@ -256,13 +293,13 @@ module Yamine
|
|
|
256
293
|
# the response. Returns :keep_alive when both sides want to reuse
|
|
257
294
|
# the connection and the response length was known.
|
|
258
295
|
def pipe_request(sock, backend, method, target, headers, buf)
|
|
259
|
-
backend
|
|
296
|
+
write_all(backend, rebuild_head(method, target, headers))
|
|
260
297
|
|
|
261
298
|
body_len = request_body_length(headers)
|
|
262
299
|
if body_len == :chunked
|
|
263
300
|
# Unclassifiable request body: stream to EOF, close after.
|
|
264
|
-
backend
|
|
265
|
-
|
|
301
|
+
write_all(backend, buf) unless buf.empty?
|
|
302
|
+
copy_stream(sock, backend)
|
|
266
303
|
relay_response_close(backend, sock)
|
|
267
304
|
return :close
|
|
268
305
|
end
|
|
@@ -270,13 +307,11 @@ module Yamine
|
|
|
270
307
|
remaining = body_len
|
|
271
308
|
unless buf.empty?
|
|
272
309
|
from_buf = buf.byteslice(0, remaining)
|
|
273
|
-
backend
|
|
310
|
+
write_all(backend, from_buf)
|
|
274
311
|
remaining -= from_buf.bytesize
|
|
275
312
|
buf = buf.byteslice(from_buf.bytesize..) || +""
|
|
276
313
|
end
|
|
277
|
-
if remaining > 0
|
|
278
|
-
IO.copy_stream(sock, backend, remaining)
|
|
279
|
-
end
|
|
314
|
+
copy_stream(sock, backend, remaining) if remaining > 0
|
|
280
315
|
|
|
281
316
|
rhead, rbuf = read_head(backend, +"")
|
|
282
317
|
if rhead.nil?
|
|
@@ -284,19 +319,17 @@ module Yamine
|
|
|
284
319
|
return :close
|
|
285
320
|
end
|
|
286
321
|
_rm, _rt, rheaders = parse_head(rhead)
|
|
287
|
-
sock
|
|
288
|
-
sock
|
|
322
|
+
write_all(sock, rhead)
|
|
323
|
+
write_all(sock, rbuf) unless rbuf.empty?
|
|
289
324
|
|
|
290
325
|
rlen = content_length(rheaders)
|
|
291
326
|
if rlen.nil?
|
|
292
327
|
# No Content-Length: close-delimited response.
|
|
293
|
-
|
|
328
|
+
copy_stream(backend, sock)
|
|
294
329
|
return :close
|
|
295
330
|
end
|
|
296
331
|
remaining = rlen - rbuf.bytesize
|
|
297
|
-
if remaining > 0
|
|
298
|
-
IO.copy_stream(backend, sock, remaining)
|
|
299
|
-
end
|
|
332
|
+
copy_stream(backend, sock, remaining) if remaining > 0
|
|
300
333
|
|
|
301
334
|
if keep_alive?(headers) && keep_alive?(rheaders)
|
|
302
335
|
# Any bytes beyond Content-Length on the backend are a second
|
|
@@ -311,9 +344,9 @@ module Yamine
|
|
|
311
344
|
rhead, rbuf = read_head(backend, +"")
|
|
312
345
|
return if rhead.nil?
|
|
313
346
|
|
|
314
|
-
sock
|
|
315
|
-
sock
|
|
316
|
-
|
|
347
|
+
write_all(sock, rhead)
|
|
348
|
+
write_all(sock, rbuf) unless rbuf.empty?
|
|
349
|
+
copy_stream(backend, sock)
|
|
317
350
|
rescue IOError, SystemCallError
|
|
318
351
|
nil
|
|
319
352
|
end
|
|
@@ -331,8 +364,8 @@ module Yamine
|
|
|
331
364
|
set_forwarded(headers, sock, tls: @tls)
|
|
332
365
|
|
|
333
366
|
backend = dial(entry)
|
|
334
|
-
backend
|
|
335
|
-
backend
|
|
367
|
+
write_all(backend, rebuild_head(method, target, headers))
|
|
368
|
+
write_all(backend, buf) unless buf.empty?
|
|
336
369
|
pipe_both(sock, backend)
|
|
337
370
|
rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
|
|
338
371
|
@on_error.call("upgrade proxy error: #{e.message}")
|
|
@@ -369,9 +402,11 @@ module Yamine
|
|
|
369
402
|
true
|
|
370
403
|
end
|
|
371
404
|
|
|
372
|
-
# Read just the header block
|
|
373
|
-
#
|
|
374
|
-
#
|
|
405
|
+
# Read just the header block without stdio buffering (so nothing is
|
|
406
|
+
# stolen from the body stream). `buf` carries bytes read ahead
|
|
407
|
+
# (pipelined requests) across calls. Returns [head, buf]. The wait
|
|
408
|
+
# for more bytes is idle-bounded: a peer that sends nothing is
|
|
409
|
+
# dropped (nil) instead of pinning the thread.
|
|
375
410
|
def read_head(sock, buf)
|
|
376
411
|
loop do
|
|
377
412
|
if (idx = buf.index("\r\n\r\n"))
|
|
@@ -379,13 +414,109 @@ module Yamine
|
|
|
379
414
|
end
|
|
380
415
|
return [nil, buf] if buf.bytesize > MAX_HEAD_BYTES
|
|
381
416
|
|
|
382
|
-
|
|
383
|
-
buf << chunk
|
|
417
|
+
buf << read_chunk(sock, CHUNK_BYTES)
|
|
384
418
|
end
|
|
385
419
|
rescue EOFError, IOError
|
|
386
420
|
[nil, +""]
|
|
387
421
|
end
|
|
388
422
|
|
|
423
|
+
# Finish a deferred TLS handshake under the idle bound. A no-op for
|
|
424
|
+
# plain sockets and for SSLSockets that already handshook (a second
|
|
425
|
+
# accept on an established connection returns immediately).
|
|
426
|
+
def tls_handshake(sock)
|
|
427
|
+
return unless sock.is_a?(OpenSSL::SSL::SSLSocket)
|
|
428
|
+
|
|
429
|
+
loop do
|
|
430
|
+
result = sock.accept_nonblock(exception: false)
|
|
431
|
+
return if result.is_a?(OpenSSL::SSL::SSLSocket)
|
|
432
|
+
|
|
433
|
+
wait_for(sock, result)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# One idle-bounded read: returns bytes, raises EOFError at end of
|
|
438
|
+
# stream, IdleTimeout when the peer is silent past the bound. Never
|
|
439
|
+
# blocks in the kernel without a select deadline, so no stalled peer
|
|
440
|
+
# pins the thread — including mid-TLS-handshake stalls, which plain
|
|
441
|
+
# readpartial would ride out forever.
|
|
442
|
+
def read_chunk(sock, size)
|
|
443
|
+
loop do
|
|
444
|
+
result = sock.read_nonblock(size, exception: false)
|
|
445
|
+
return result if result.is_a?(String)
|
|
446
|
+
raise EOFError, "end of file reached" if result.nil?
|
|
447
|
+
|
|
448
|
+
wait_for(sock, result)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Idle-bounded write of the whole string. A peer that stops reading
|
|
453
|
+
# (full window) stalls here only until the bound, then IdleTimeout.
|
|
454
|
+
def write_all(sock, data)
|
|
455
|
+
offset = 0
|
|
456
|
+
while offset < data.bytesize
|
|
457
|
+
result = sock.write_nonblock(data.byteslice(offset..), exception: false)
|
|
458
|
+
if result.is_a?(Integer)
|
|
459
|
+
# A zero write made no progress: wait like a blocked writer
|
|
460
|
+
# instead of spinning.
|
|
461
|
+
result.zero? ? wait_for(sock, :wait_writable) : offset += result
|
|
462
|
+
else
|
|
463
|
+
wait_for(sock, result)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
offset
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Idle-bounded relay. length nil copies to EOF (close-delimited or
|
|
470
|
+
# chunked-upload bodies); otherwise exactly length bytes. Every byte
|
|
471
|
+
# that moves resets the idle clock, so a slow trickle survives while
|
|
472
|
+
# a stalled source is abandoned with IdleTimeout. A truncated fixed
|
|
473
|
+
# body re-raises EOFError like IO.copy_stream did.
|
|
474
|
+
def copy_stream(src, dst, length = nil)
|
|
475
|
+
remaining = length
|
|
476
|
+
loop do
|
|
477
|
+
break if !remaining.nil? && remaining <= 0
|
|
478
|
+
|
|
479
|
+
size = remaining.nil? ? CHUNK_BYTES : [CHUNK_BYTES, remaining].min
|
|
480
|
+
begin
|
|
481
|
+
data = read_chunk(src, size)
|
|
482
|
+
rescue EOFError
|
|
483
|
+
raise if !remaining.nil? && remaining.positive?
|
|
484
|
+
|
|
485
|
+
break
|
|
486
|
+
end
|
|
487
|
+
write_all(dst, data)
|
|
488
|
+
remaining -= data.bytesize unless remaining.nil?
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Block until the socket is ready for the direction a nonblocking op
|
|
493
|
+
# asked for; IdleTimeout when the bound passes with no progress.
|
|
494
|
+
def wait_for(sock, wait_kind)
|
|
495
|
+
if wait_kind == :wait_readable
|
|
496
|
+
raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes" unless readable?(sock)
|
|
497
|
+
elsif !writable?(sock)
|
|
498
|
+
raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes"
|
|
499
|
+
end
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# select(2) with the idle bound. SSL-buffered bytes count as
|
|
504
|
+
# readable without a syscall. A closed socket raises in select —
|
|
505
|
+
# report not-ready and let the nonblocking op raise the real error.
|
|
506
|
+
def readable?(sock)
|
|
507
|
+
return true if sock.respond_to?(:pending) && sock.pending.positive?
|
|
508
|
+
|
|
509
|
+
!IO.select([sock], nil, nil, @idle_timeout).nil?
|
|
510
|
+
rescue IOError, SystemCallError
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def writable?(sock)
|
|
515
|
+
!IO.select(nil, [sock], nil, @idle_timeout).nil?
|
|
516
|
+
rescue IOError, SystemCallError
|
|
517
|
+
false
|
|
518
|
+
end
|
|
519
|
+
|
|
389
520
|
def parse_head(head)
|
|
390
521
|
lines = head.split("\r\n")
|
|
391
522
|
method, target, _version = lines.first.to_s.split(" ", 3)
|
|
@@ -432,7 +563,9 @@ module Yamine
|
|
|
432
563
|
end
|
|
433
564
|
|
|
434
565
|
# Raw bidirectional pump for upgrades; either side closing unblocks
|
|
435
|
-
# the other.
|
|
566
|
+
# the other. Deliberately outside the idle bound: an idle websocket
|
|
567
|
+
# is legitimate traffic (silence is the normal state), and framing
|
|
568
|
+
# no longer applies once the connection is hijacked.
|
|
436
569
|
def pipe_both(client, backend)
|
|
437
570
|
t1 = Thread.new do
|
|
438
571
|
IO.copy_stream(client, backend)
|
|
@@ -508,11 +641,11 @@ module Yamine
|
|
|
508
641
|
|
|
509
642
|
def respond(sock, status, body)
|
|
510
643
|
message = { 404 => "Not Found", 502 => "Bad Gateway", 508 => "Loop Detected" }[status]
|
|
511
|
-
sock
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
644
|
+
write_all(sock, "HTTP/1.1 #{status} #{message}\r\n" \
|
|
645
|
+
"Content-Type: text/html\r\n" \
|
|
646
|
+
"#{HEALTH_HEADER}: 1\r\n" \
|
|
647
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
648
|
+
"Connection: close\r\n\r\n#{body}")
|
|
516
649
|
rescue IOError, SystemCallError
|
|
517
650
|
nil
|
|
518
651
|
end
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -247,20 +247,38 @@ module Yamine
|
|
|
247
247
|
# explicitly because sudo does not preserve the environment.
|
|
248
248
|
# Returns pid.
|
|
249
249
|
def spawn_daemon(store:, port:, tls:, sudo: false, tlds: nil)
|
|
250
|
+
# Never spawn over a live proxy: the readiness probe below would
|
|
251
|
+
# pass on ITS behalf while our child dies of EACCES, and the dead
|
|
252
|
+
# pid would then be recorded over the real proxy's state.
|
|
253
|
+
if ours?(port, tls: tls)
|
|
254
|
+
raise ProxyAlreadyRunningError,
|
|
255
|
+
"A yamine proxy is already serving on port #{port} — not starting another."
|
|
256
|
+
end
|
|
250
257
|
store.ensure_dir
|
|
251
258
|
log_path = File.join(store.dir, LOG_NAME)
|
|
252
259
|
Log.rotate(log_path)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
if sudo
|
|
261
|
+
# A privileged spawn never executes the gem directory as root:
|
|
262
|
+
# make sure the staged payload exists first (staging under this
|
|
263
|
+
# same human-authorized sudo when missing), then spawn the
|
|
264
|
+
# staged copy. The `env` prefix stays: no grant rule covers this
|
|
265
|
+
# path, and the long-lived daemon needs the exact state dir.
|
|
266
|
+
ensure_staged_payload!(store)
|
|
267
|
+
end
|
|
268
|
+
cmd = daemon_cmd(store: store, port: port, tls: tls, sudo: sudo, tlds: tlds)
|
|
259
269
|
pid = spawn({ "YAMINE_STATE_DIR" => store.dir }, *cmd,
|
|
260
270
|
out: log_path, err: [:child, :out])
|
|
261
271
|
Process.detach(pid)
|
|
262
272
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
|
|
263
273
|
until ours?(port, tls: tls)
|
|
274
|
+
# The child died before serving (EACCES on a privileged port,
|
|
275
|
+
# EADDRINUSE on a taken one): fail now with its log instead of
|
|
276
|
+
# waiting out the deadline and blaming a timeout.
|
|
277
|
+
unless pid_alive?(pid)
|
|
278
|
+
raise ProxyNotRunningError,
|
|
279
|
+
"Proxy exited before serving on port #{port}. " \
|
|
280
|
+
"Log (#{log_path}):\n#{log_tail(log_path)}"
|
|
281
|
+
end
|
|
264
282
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
265
283
|
raise ProxyNotRunningError,
|
|
266
284
|
"Proxy did not start on port #{port}. " \
|
|
@@ -269,10 +287,55 @@ module Yamine
|
|
|
269
287
|
|
|
270
288
|
sleep 0.25
|
|
271
289
|
end
|
|
290
|
+
# The probe can pass on someone else's behalf (a root service that
|
|
291
|
+
# started answering between our pre-spawn check and now, while our
|
|
292
|
+
# child died): never record a pid that is not alive.
|
|
293
|
+
unless pid_alive?(pid)
|
|
294
|
+
raise ProxyNotRunningError,
|
|
295
|
+
"Proxy process #{pid} is not running, but port #{port} answers. " \
|
|
296
|
+
"Another proxy (likely the root service) is serving it — not recording #{pid}. " \
|
|
297
|
+
"Log (#{log_path}):\n#{log_tail(log_path)}"
|
|
298
|
+
end
|
|
272
299
|
write_proxy_state(store, pid: pid, port: port, tls: tls)
|
|
273
300
|
pid
|
|
274
301
|
end
|
|
275
302
|
|
|
303
|
+
# The exact argv a daemon spawn executes, extracted so the
|
|
304
|
+
# privileged shape is pinnable without spawning processes. The
|
|
305
|
+
# payload is the root-owned staged copy when sudo is involved —
|
|
306
|
+
# never the user-writable gem directory — and the gem path when it
|
|
307
|
+
# is not.
|
|
308
|
+
def daemon_cmd(store:, port:, tls:, sudo:, tlds: nil)
|
|
309
|
+
payload = sudo ? PrivilegedPayload.bin_path : bin_path
|
|
310
|
+
args = [RbConfig.ruby, payload,
|
|
311
|
+
"proxy", "start", "--foreground", "--port", port.to_s]
|
|
312
|
+
args << "--no-tls" unless tls
|
|
313
|
+
Array(tlds).each { |t| args.concat(["--tld", t]) }
|
|
314
|
+
state_arg = "YAMINE_STATE_DIR=#{store.dir}"
|
|
315
|
+
sudo ? ["sudo", "env", state_arg, *args] : [*args]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Guarantee the staged payload before a privileged spawn. Runs the
|
|
319
|
+
# gem source once under the same human-authorized sudo that is
|
|
320
|
+
# about to launch the daemon (plain sudo, may prompt — never
|
|
321
|
+
# granted, never passwordless), so the daemon itself runs only the
|
|
322
|
+
# staged copy.
|
|
323
|
+
def ensure_staged_payload!(store)
|
|
324
|
+
return true if PrivilegedPayload.staged?
|
|
325
|
+
|
|
326
|
+
puts "Staging the privileged payload (sudo required)..."
|
|
327
|
+
stage_cmd = ["sudo", "env", "YAMINE_STATE_DIR=#{store.dir}",
|
|
328
|
+
RbConfig.ruby, bin_path, "service", "stage", "--internal"]
|
|
329
|
+
ok = Command.run(*stage_cmd)
|
|
330
|
+
unless ok && PrivilegedPayload.staged?
|
|
331
|
+
raise ProxyNotRunningError,
|
|
332
|
+
"Could not stage the privileged payload as root. " \
|
|
333
|
+
"Run once in a terminal: sudo yamine service install " \
|
|
334
|
+
"(or: yamine setup) — then re-run."
|
|
335
|
+
end
|
|
336
|
+
true
|
|
337
|
+
end
|
|
338
|
+
|
|
276
339
|
def log_tail(path, lines: 15)
|
|
277
340
|
return "(no log yet)" unless File.file?(path)
|
|
278
341
|
|
|
@@ -296,6 +359,13 @@ module Yamine
|
|
|
296
359
|
return :unknown_process
|
|
297
360
|
end
|
|
298
361
|
unless pid_alive?(pid)
|
|
362
|
+
# The recorded process is gone, but someone may still be serving
|
|
363
|
+
# the recorded port (a root service the CLI cannot signal):
|
|
364
|
+
# clearing the state then would orphan the live proxy, leaving
|
|
365
|
+
# nothing on disk to find or stop it by. Probe first.
|
|
366
|
+
if port && ours?(port, tls: proxy_tls(store))
|
|
367
|
+
return :needs_root
|
|
368
|
+
end
|
|
299
369
|
clear_pid(store)
|
|
300
370
|
return :stale
|
|
301
371
|
end
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "yamine/worktrees"
|
|
|
13
13
|
require_relative "yamine/framework"
|
|
14
14
|
require_relative "yamine/config"
|
|
15
15
|
require_relative "yamine/ownership"
|
|
16
|
+
require_relative "yamine/privileged_payload"
|
|
16
17
|
require_relative "yamine/command"
|
|
17
18
|
require_relative "yamine/log"
|
|
18
19
|
require_relative "yamine/route_store"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yamine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/yamine/log.rb
|
|
104
104
|
- lib/yamine/ownership.rb
|
|
105
105
|
- lib/yamine/ports.rb
|
|
106
|
+
- lib/yamine/privileged_payload.rb
|
|
106
107
|
- lib/yamine/probe.rb
|
|
107
108
|
- lib/yamine/procfile.rb
|
|
108
109
|
- lib/yamine/proxy.rb
|