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.
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
- break unless @running
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.write(rebuild_head(method, target, headers))
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.write(buf) unless buf.empty?
265
- IO.copy_stream(sock, backend)
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.write(from_buf)
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.write(rhead)
288
- sock.write(rbuf) unless rbuf.empty?
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
- IO.copy_stream(backend, sock)
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.write(rhead)
315
- sock.write(rbuf) unless rbuf.empty?
316
- IO.copy_stream(backend, sock)
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.write(rebuild_head(method, target, headers))
335
- backend.write(buf) unless buf.empty?
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 with readpartial (no stdio buffering,
373
- # so nothing is stolen from the body stream). `buf` carries bytes
374
- # read ahead (pipelined requests) across calls. Returns [head, buf].
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
- chunk = sock.readpartial(16_384)
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.write("HTTP/1.1 #{status} #{message}\r\n" \
512
- "Content-Type: text/html\r\n" \
513
- "#{HEALTH_HEADER}: 1\r\n" \
514
- "Content-Length: #{body.bytesize}\r\n" \
515
- "Connection: close\r\n\r\n#{body}")
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
@@ -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
- args = [RbConfig.ruby, bin_path,
254
- "proxy", "start", "--foreground", "--port", port.to_s]
255
- args << "--no-tls" unless tls
256
- Array(tlds).each { |t| args.concat(["--tld", t]) }
257
- state_arg = "YAMINE_STATE_DIR=#{store.dir}"
258
- cmd = sudo ? ["sudo", "env", state_arg, *args] : [*args]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.19.0"
4
+ VERSION = "0.21.0"
5
5
  end
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.19.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