yamine 0.22.1 → 0.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f20b021d3a702ab4aef776ac24e10683b12c1b4ba58b7ad4e5b11b91316eb78
4
- data.tar.gz: 4ff8a2e59f075f38aa81d084d9aea45041346790aefd687ef2fc08669bc34737
3
+ metadata.gz: 308817ac04ecc72d2d0af8ca1a9262d502d5dfb27d4b21a11403c12eebac4aa0
4
+ data.tar.gz: 0d8c7fdc05ef6bb45ca01165901f14d9d9d17b80e4e86d1c32614fb8f12594a0
5
5
  SHA512:
6
- metadata.gz: 2e2542be9656d87375b9ab29ac2862f33b2be289c7932aa99f2f29abf3b2a47f40e24623f2ab8b902021e6b42fe4ed48ebe7dfc4a86a3184ddf4f859cb359d40
7
- data.tar.gz: 85d76ec111ee796d23b89879d414bf5a248504f58983a0064c3d43ffaa88d532a0f3b5d6542f21b09d2fba5acb57f20e673fcb5f7d13490108f321adbf1eb896
6
+ metadata.gz: ffc43e16572dee62e053441f56aa2472fbf3c388b80fdbbae17a79fbae0a4e6fae03819dd72d0830239aaaabfa8d04b1952433083cb9339fb7776b2b2bdbbf5e
7
+ data.tar.gz: d51b33e1780b9cd49cc87655bdb51adfc007736c29b67ac580fda2c2126c7cf80471e24fc4446f805f9230435af6e682132eaed599de5d7222e2125cb3c1294b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.0] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - **A stalled response body closes the connection instead of appending a
10
+ 502 into a live 200.** When a response relay went quiet past the idle
11
+ bound, the IdleTimeout escaped `forward`'s two rescues (it is neither
12
+ HeadTimeout nor EOFError), landed in `handle`'s generic IOError rescue,
13
+ and rendered a whole second response head into the body the browser was
14
+ already reading. The page died of the corruption with no error anywhere
15
+ naming a cause — DevTools even showed the 502 as if a server had sent
16
+ it. A response whose head has been sent can now only end one way: the
17
+ connection closes, the stall is logged with the request that died, and
18
+ no second head is ever written.
19
+ - **`backend-refused` no longer lies about what failed.** `dial` mapped
20
+ every SystemCallError to :refused, so a proxy out of file descriptors
21
+ answered "Nothing is listening at 127.0.0.1:4486" and sent the reader
22
+ off to restart an app that was perfectly fine. EMFILE/ENFILE now read
23
+ as `proxy-exhausted` and blame the proxy's own limit; every other dial
24
+ error reads as `proxy-unreachable`; a backend that accepts and closes
25
+ without answering reads as `backend-gone` instead of masquerading as
26
+ refused. The 502 page says which of these it was, and carries the fix
27
+ for that failure rather than a start command for an app that is up.
28
+
29
+ ### Added
30
+
31
+ - **The root daemon can finally be debugged.** The launchd plist and the
32
+ systemd unit carried no log paths, so the one proxy process serving
33
+ traffic discarded every error it ever reported — the mislabeled 502
34
+ above was invisible precisely because of this. Both streams now land
35
+ in `proxy.log` in the state dir, and the daemon rotates that log at
36
+ boot (one generation kept), the same rule the spawned proxy already
37
+ applied.
38
+
5
39
  ## [0.22.1] - 2026-09-29
6
40
 
7
41
  ### Fixed
@@ -97,6 +97,11 @@ module Yamine
97
97
  if foreground
98
98
  write_tls_marker(ctx, tls)
99
99
  write_tlds_file(ctx, tlds)
100
+ # The daemon appends to this log for its whole life (launchd
101
+ # and systemd both redirect their streams here), so each boot
102
+ # rotates first — one generation kept, the same rule the
103
+ # spawned proxy's Log.open_append already applies.
104
+ Log.rotate(File.join(ctx.store.dir, "proxy.log"))
100
105
  # Record BEFORE serving. This process (the launchd/systemd
101
106
  # service, or a hand-run foreground proxy) IS the proxy, and
102
107
  # every reader trusts these files: doctor, Context's URL
@@ -468,8 +473,37 @@ module Yamine
468
473
  # the user-writable gem directory it was staged from. (The
469
474
  # interpreter stays RbConfig.ruby: no root-owned Ruby >= 3.2
470
475
  # exists, so that residual risk is reported, not hidden.)
476
+ plist = launchd_plist(state_dir: state_dir, home: home)
477
+ remove_legacy_launchd(dir)
478
+ FileUtils.mkdir_p(dir)
479
+ path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
480
+ File.write(path, plist)
481
+ File.chmod(0o644, path)
482
+ # launchd requires /Library/LaunchDaemons plists to be
483
+ # root-owned; we are root here (sudo re-exec). Enforce it
484
+ # explicitly: File.write keeps an existing file's owner, so a
485
+ # stale user-owned plist from an older version would otherwise
486
+ # survive the overwrite and bootstrap fails with error 5.
487
+ # Trust the CA into the System keychain while elevated: silent
488
+ # (no GUI popup) and trusted for every user on the machine.
489
+ File.chown(0, 0, path) if Process.uid.zero?
490
+ ensure_system_ca_trust
491
+ puts " Registering the launchd service on port 443..."
492
+ launchctl_bootstrap(path)
493
+ puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
494
+ puts " Payload: #{PrivilegedPayload.bin_path} (root-owned, version-independent)."
495
+ puts " Log: #{File.join(state_dir, "proxy.log")}."
496
+ end
497
+
498
+ # Pure plist builder (testable without root). Both output streams
499
+ # point at the state dir's proxy.log: a root daemon whose errors
500
+ # launchd discards cannot be debugged at all, and one whole
501
+ # misdiagnosed failure class came from exactly that blindness —
502
+ # the proxy's own account of a dial failure never reached disk.
503
+ def launchd_plist(state_dir:, home:)
471
504
  payload_bin = PrivilegedPayload.bin_path
472
- plist = <<~PLIST
505
+ log_path = File.join(state_dir, "proxy.log")
506
+ <<~PLIST
473
507
  <?xml version="1.0" encoding="UTF-8"?>
474
508
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
475
509
  <plist version="1.0">
@@ -489,27 +523,11 @@ module Yamine
489
523
  </dict>
490
524
  <key>KeepAlive</key><true/>
491
525
  <key>RunAtLoad</key><true/>
526
+ <key>StandardOutPath</key><string>#{log_path}</string>
527
+ <key>StandardErrorPath</key><string>#{log_path}</string>
492
528
  </dict>
493
529
  </plist>
494
530
  PLIST
495
- remove_legacy_launchd(dir)
496
- FileUtils.mkdir_p(dir)
497
- path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
498
- File.write(path, plist)
499
- File.chmod(0o644, path)
500
- # launchd requires /Library/LaunchDaemons plists to be
501
- # root-owned; we are root here (sudo re-exec). Enforce it
502
- # explicitly: File.write keeps an existing file's owner, so a
503
- # stale user-owned plist from an older version would otherwise
504
- # survive the overwrite and bootstrap fails with error 5.
505
- # Trust the CA into the System keychain while elevated: silent
506
- # (no GUI popup) and trusted for every user on the machine.
507
- File.chown(0, 0, path) if Process.uid.zero?
508
- ensure_system_ca_trust
509
- puts " Registering the launchd service on port 443..."
510
- launchctl_bootstrap(path)
511
- puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
512
- puts " Payload: #{payload_bin} (root-owned, version-independent)."
513
531
  end
514
532
 
515
533
  # True when the trust store covers the CA this machine should be
@@ -587,9 +605,12 @@ module Yamine
587
605
  # Pure unit-file builder (testable without root). Binds 80/443 at
588
606
  # boot; the proxy runs with the invoking user's state dir. The
589
607
  # payload is the root-owned staged path — never the gem directory.
590
- def systemd_unit
591
- home = user_home_for_service
592
- state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
608
+ # Both output streams append to the state dir's proxy.log, matching
609
+ # the launchd unit, so a daemon that misbehaves can be read.
610
+ def systemd_unit(state_dir: nil, home: nil)
611
+ home ||= user_home_for_service
612
+ state_dir ||= ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
613
+ log_path = File.join(state_dir, "proxy.log")
593
614
  <<~UNIT
594
615
  # /etc/systemd/system/yamine.service (binds 80/443 at boot)
595
616
  [Unit]
@@ -599,6 +620,8 @@ module Yamine
599
620
  ExecStart=#{RbConfig.ruby} #{PrivilegedPayload.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
600
621
  Environment=YAMINE_STATE_DIR=#{state_dir}
601
622
  Environment=HOME=#{home}
623
+ StandardOutput=append:#{log_path}
624
+ StandardError=append:#{log_path}
602
625
 
603
626
  [Install]
604
627
  WantedBy=multi-user.target
data/lib/yamine/proxy.rb CHANGED
@@ -329,6 +329,13 @@ module Yamine
329
329
  keep, reason = forward(sock, entry, method, target, headers, buf)
330
330
  end
331
331
 
332
+ if reason == :body_lost
333
+ # The response head already reached the client; there is no
334
+ # answer left to give, only a connection to close. A 502 here
335
+ # would be garbage appended to a live response.
336
+ break
337
+ end
338
+
332
339
  if reason
333
340
  render_bad_gateway(sock, entry, host, reason)
334
341
  break
@@ -344,7 +351,7 @@ module Yamine
344
351
 
345
352
  # One attempt at the backend: dial, forward the request, relay the
346
353
  # response. Returns [keep_alive?, reason], where reason is nil on
347
- # success and otherwise names which backend failure this was. That one
354
+ # success and otherwise names which failure this was. That one
348
355
  # value decides both whether a retry is safe and what the 502 says, so
349
356
  # "refused" and "silent" can never drift apart again.
350
357
  #
@@ -355,8 +362,8 @@ module Yamine
355
362
  begin
356
363
  backend = dial(entry)
357
364
  rescue SystemCallError => e
358
- @on_error.call("dial failed for #{entry["hostname"]}: #{e.message}")
359
- return [:close, :refused]
365
+ @on_error.call("dial failed for #{entry["hostname"]}: #{e.class}: #{e.message}")
366
+ return [:close, failure_reason(e)]
360
367
  end
361
368
 
362
369
  begin
@@ -370,15 +377,29 @@ module Yamine
370
377
  end
371
378
  end
372
379
 
380
+ # What a failed dial means. ECONNREFUSED is the one failure that
381
+ # indicts the backend; the rest indict the path or the proxy itself,
382
+ # and reporting them as "Nothing is listening" sends the reader off to
383
+ # restart an app that is perfectly fine — an fd-exhausted proxy spent
384
+ # one whole debugging evening dressed as a dead backend.
385
+ def failure_reason(error)
386
+ case error
387
+ when Errno::ECONNREFUSED then :refused
388
+ when Errno::EMFILE, Errno::ENFILE then :exhausted
389
+ else :unreachable
390
+ end
391
+ end
392
+
373
393
  # A refused dial never reached the app, so any method replays safely —
374
394
  # the request body is still unread on the client socket. A head
375
- # timeout means the head DID reach the app, so the only safe replays
376
- # are the bodiless methods: the body has already been consumed off the
377
- # client socket and cannot be faithfully resent, and a duplicated
378
- # message is a worse outcome than a slow page.
395
+ # timeout or an exhausted proxy means the attempt died on our side of
396
+ # the wire, so the only safe replays are the bodiless methods: the
397
+ # body has already been consumed off the client socket and cannot be
398
+ # faithfully resent, and a duplicated message is a worse outcome than
399
+ # a slow page.
379
400
  def retriable?(reason, method, headers)
380
401
  return true if reason == :refused
381
- return false unless reason == :silent
402
+ return false unless [:silent, :exhausted].include?(reason)
382
403
  return false unless %w[GET HEAD OPTIONS].include?(method.to_s.upcase)
383
404
 
384
405
  request_body_length(headers) == 0
@@ -413,17 +434,43 @@ module Yamine
413
434
  return [:close, :gone] if rhead.nil?
414
435
 
415
436
  _rm, _rt, rheaders = parse_head(rhead)
416
- write_all(sock, rhead)
417
- write_all(sock, rbuf) unless rbuf.empty?
418
-
419
- rlen = content_length(rheaders)
420
- if rlen.nil?
421
- # No Content-Length: close-delimited response.
422
- copy_stream(backend, sock)
423
- return [:close, nil]
437
+ relay_after_head(sock, backend, rhead, rbuf, rheaders, headers, target)
438
+ end
439
+
440
+ # Everything from the response head onward. Once the head is on the
441
+ # wire the 502 vocabulary is spent: a failure here can only end the
442
+ # connection, never answer it, and rendering a 502 into the middle of
443
+ # a live 200 corrupts the stream the browser is already reading (the
444
+ # second head arrives as body bytes; the page dies of it with no
445
+ # error naming anything). [:close, :body_lost] tells handle to close
446
+ # quietly instead — the stall itself is logged with the request that
447
+ # died, since a relay that goes quiet mid-body is exactly the failure
448
+ # that is otherwise invisible.
449
+ def relay_after_head(sock, backend, rhead, rbuf, rheaders, headers, target)
450
+ begin
451
+ write_all(sock, rhead)
452
+ write_all(sock, rbuf) unless rbuf.empty?
453
+
454
+ rlen = content_length(rheaders)
455
+ if rlen.nil?
456
+ # No Content-Length: close-delimited response.
457
+ copy_stream(backend, sock)
458
+ return [:close, nil]
459
+ end
460
+ remaining = rlen - rbuf.bytesize
461
+ copy_stream(backend, sock, remaining) if remaining > 0
462
+ rescue EOFError
463
+ @on_error.call("backend for #{headers["host"]}#{target} closed mid-body, " \
464
+ "before its Content-Length of #{rheaders["content-length"]} was met")
465
+ return [:close, :body_lost]
466
+ rescue IdleTimeout => e
467
+ @on_error.call("response for #{headers["host"]}#{target} died mid-body: #{e.message}")
468
+ return [:close, :body_lost]
469
+ rescue IOError, SystemCallError
470
+ # The client or the backend hung up mid-relay — ordinary web
471
+ # traffic (a cancelled fetch, a departed tab), not a failure.
472
+ return [:close, :body_lost]
424
473
  end
425
- remaining = rlen - rbuf.bytesize
426
- copy_stream(backend, sock, remaining) if remaining > 0
427
474
 
428
475
  if keep_alive?(headers) && keep_alive?(rheaders)
429
476
  # Any bytes beyond Content-Length on the backend are a second
@@ -783,8 +830,7 @@ module Yamine
783
830
  # command to run. Modeled on render_not_found, which already answers
784
831
  # "nobody knows what this hostname is" properly.
785
832
  def render_bad_gateway(sock, entry, host, reason = :refused)
786
- kind = reason == :silent ? "backend-silent" : "backend-refused"
787
- headers = { ERROR_HEADER => kind }
833
+ headers = { ERROR_HEADER => error_kind(reason) }
788
834
  bare = Hostname.strip_port(host)
789
835
  # Same DNS-rebinding boundary as render_not_found: a Host outside our
790
836
  # TLDs is a website that got us to answer, not the local developer
@@ -793,16 +839,31 @@ module Yamine
793
839
 
794
840
  target = entry ? entry["target"].to_s : ""
795
841
  body = "<h1>Bad Gateway</h1><p>#{what_happened(reason, target)}</p>" \
796
- "#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry)}"
842
+ "#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry, reason)}"
797
843
  respond(sock, 502, body, headers: headers)
798
844
  rescue IOError, SystemCallError
799
845
  nil
800
846
  end
801
847
 
802
- # Which of the two failures this was, said the way each one has to be
803
- # read: not listening means start the app, silent means go find out why
804
- # an app that is up is not answering. One "not responding" for both
805
- # sends the reader to the wrong place either way.
848
+ # The machine-readable failure class. Agents branch on this without
849
+ # parsing the page, so each failure must carry its own name — a closed
850
+ # connection labeled "refused", or a proxy out of file descriptors
851
+ # labeled "refused", points the reader at the app when the app is fine.
852
+ def error_kind(reason)
853
+ case reason
854
+ when :silent then "backend-silent"
855
+ when :gone then "backend-gone"
856
+ when :exhausted then "proxy-exhausted"
857
+ when :unreachable then "proxy-unreachable"
858
+ else "backend-refused"
859
+ end
860
+ end
861
+
862
+ # Which of the failures this was, said the way each one has to be
863
+ # read: not listening means start the app, silent means go find out
864
+ # why an app that is up is not answering, exhausted means the trouble
865
+ # is the proxy's own resource limit. One "not responding" for all of
866
+ # them sends the reader to the wrong place every time.
806
867
  def what_happened(reason, target)
807
868
  at = target.empty? ? "" : " at <code>#{escape(target)}</code>"
808
869
  case reason
@@ -812,6 +873,12 @@ module Yamine
812
873
  when :gone
813
874
  "The backend#{at} accepted the connection, then closed it without " \
814
875
  "sending a response."
876
+ when :exhausted
877
+ "The proxy ran out of file descriptors dialing#{at} — the limit is the " \
878
+ "proxy process's own, not the app's, and the backend may be perfectly fine."
879
+ when :unreachable
880
+ "The proxy could not reach the backend#{at} — the address is wrong or the " \
881
+ "path there failed, which is not the same as nothing listening."
815
882
  else
816
883
  "Nothing is listening#{at}."
817
884
  end
@@ -832,7 +899,14 @@ module Yamine
832
899
  "<p>The backend for <strong>#{escape(host)}</strong> is registered#{by}#{where}.</p>"
833
900
  end
834
901
 
835
- def bad_gateway_fix(entry)
902
+ def bad_gateway_fix(entry, reason)
903
+ if [:exhausted, :unreachable].include?(reason)
904
+ # The app is not the suspect here, so the start command would be
905
+ # the wrong advice on a page that already says so.
906
+ return "<p>The backend looks up — check the proxy's own log: " \
907
+ "<code>proxy.log</code> in the yamine state dir.</p>"
908
+ end
909
+
836
910
  dir = entry&.dig("spec", "dir")
837
911
  return "<p>Start it with <code>yamine start</code> in that app's directory.</p>" unless dir
838
912
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.22.1"
4
+ VERSION = "0.23.0"
5
5
  end
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.22.1
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto