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 +4 -4
- data/CHANGELOG.md +34 -0
- data/lib/yamine/cli/system.rb +45 -22
- data/lib/yamine/proxy.rb +100 -26
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 308817ac04ecc72d2d0af8ca1a9262d502d5dfb27d4b21a11403c12eebac4aa0
|
|
4
|
+
data.tar.gz: 0d8c7fdc05ef6bb45ca01165901f14d9d9d17b80e4e86d1c32614fb8f12594a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
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,
|
|
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
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
# message is a worse outcome than
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
803
|
-
#
|
|
804
|
-
#
|
|
805
|
-
#
|
|
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
|
|
data/lib/yamine/version.rb
CHANGED