yamine 0.22.0 → 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 +72 -0
- data/lib/yamine/cli/system.rb +52 -29
- data/lib/yamine/doctor.rb +9 -0
- data/lib/yamine/proxy.rb +123 -33
- data/lib/yamine/trust.rb +183 -8
- 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,78 @@
|
|
|
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
|
+
|
|
39
|
+
## [0.22.1] - 2026-09-29
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **macOS trusts the local CA now, instead of merely filing it.** A
|
|
44
|
+
non-elevated `yamine trust` added the certificate without naming any
|
|
45
|
+
policies, so macOS recorded a trust-store entry with no settings at
|
|
46
|
+
all: the CA sat in the login keychain trusted for nothing, and every
|
|
47
|
+
browser answered `ERR_CERT_AUTHORITY_INVALID` on every
|
|
48
|
+
`https://*.localhost` route. The add now passes `-p ssl -p basic` —
|
|
49
|
+
the same thing Keychain Access writes for Secure Sockets Layer + X.509
|
|
50
|
+
Basic — and `yamine trust` re-reads the trust store afterwards rather
|
|
51
|
+
than believing the exit status, so an add that recorded nothing is a
|
|
52
|
+
failure that names the exact `security add-trusted-cert` to run
|
|
53
|
+
instead of a success the browser will contradict.
|
|
54
|
+
- **A CA that is in the keychain but untrusted repairs itself.** The
|
|
55
|
+
check that decided whether to trust compared fingerprints alone, so
|
|
56
|
+
once that bare certificate was in a keychain every later boot
|
|
57
|
+
concluded it was trusted and never touched it again — the check that
|
|
58
|
+
should have caught the broken state was the one hiding it. It now
|
|
59
|
+
needs both halves: the certificate in a keychain *and* a trust
|
|
60
|
+
setting recorded for its fingerprint. A machine left with a
|
|
61
|
+
policy-less entry therefore self-heals on the next `yamine trust` or
|
|
62
|
+
proxy boot, and one whose trust store cannot be read is left alone
|
|
63
|
+
rather than re-adding on every boot.
|
|
64
|
+
- **A proxy running as a normal user trusts the CA too.** Trust used to
|
|
65
|
+
be attempted only when the proxy was root, so a first run without
|
|
66
|
+
`sudo` never tried and installed a CA that could not work. Every proxy
|
|
67
|
+
now ensures trust, elevated or not, and reports a failed attempt
|
|
68
|
+
rather than swallowing it. `YAMINE_SKIP_CA_TRUST` is the escape
|
|
69
|
+
hatch for machines whose CA arrives by MDM or a hand-run
|
|
70
|
+
`security add-trusted-cert`.
|
|
71
|
+
- **`yamine doctor` names a CA the OS does not trust.** The recorded
|
|
72
|
+
marker says yamine trusted this certificate; it says nothing about
|
|
73
|
+
whether macOS agreed. `doctor` asks the OS too, so a CA that is
|
|
74
|
+
installed but untrusted — the state where every route fails TLS —
|
|
75
|
+
reads as "CA is installed but macOS does not trust it" and points at
|
|
76
|
+
`yamine trust` instead of passing as healthy.
|
|
5
77
|
## [0.22.0] - 2026-09-29
|
|
6
78
|
|
|
7
79
|
### Added
|
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
|
|
@@ -517,15 +535,15 @@ module Yamine
|
|
|
517
535
|
# expiring, or renamed.
|
|
518
536
|
#
|
|
519
537
|
# The ordering is the whole point, so it lives in one place rather
|
|
520
|
-
# than at each call site.
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
# the CA is current
|
|
524
|
-
# lets the proxy come up serving a
|
|
525
|
-
#
|
|
538
|
+
# than at each call site. The state-dir marker is only half of it
|
|
539
|
+
# (`Trust.trusted?` also asks the OS): a marker that says "we
|
|
540
|
+
# trusted this" survives a keychain that never took the setting,
|
|
541
|
+
# so checking it before the CA is current — or on its own — reads
|
|
542
|
+
# a stale match, skips trust, and lets the proxy come up serving a
|
|
543
|
+
# CA no browser trusts.
|
|
526
544
|
def ca_current_and_trusted?(dir = Certs.state_dir)
|
|
527
545
|
Certs.ensure_ca(dir)
|
|
528
|
-
|
|
546
|
+
Trust.trusted?(dir)
|
|
529
547
|
end
|
|
530
548
|
|
|
531
549
|
# Root-only CA trust: the System keychain (all users, no prompt).
|
|
@@ -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/doctor.rb
CHANGED
|
@@ -192,6 +192,15 @@ module Yamine
|
|
|
192
192
|
unless Certs.trusted?(dir)
|
|
193
193
|
return Check.new(name: "ca", ok: false, message: "CA not trusted — run: yamine trust")
|
|
194
194
|
end
|
|
195
|
+
# The marker says WE trusted this certificate; it says nothing about
|
|
196
|
+
# whether the OS recorded a trust setting for it. A CA sitting in
|
|
197
|
+
# the keychain with no trust setting is the state that produced
|
|
198
|
+
# ERR_CERT_AUTHORITY_INVALID on every route, with the marker
|
|
199
|
+
# cheerfully reporting success.
|
|
200
|
+
unless Trust.trusted?(dir)
|
|
201
|
+
return Check.new(name: "ca", ok: false,
|
|
202
|
+
message: "CA is installed but macOS does not trust it — run: yamine trust")
|
|
203
|
+
end
|
|
195
204
|
|
|
196
205
|
# Trusted CAs that are not the one now on disk. They accumulate from
|
|
197
206
|
# CA regeneration (missing, expiring, or renamed — the ask-local
|
data/lib/yamine/proxy.rb
CHANGED
|
@@ -107,7 +107,7 @@ module Yamine
|
|
|
107
107
|
servers.each { |s| s.listen(@port) }
|
|
108
108
|
@port = servers.first.addr[1]
|
|
109
109
|
Ownership.chown_state_dir(@store.dir)
|
|
110
|
-
|
|
110
|
+
ensure_ca_trust
|
|
111
111
|
trap("INT") { stop(servers) }
|
|
112
112
|
trap("TERM") do
|
|
113
113
|
@supervisor&.shutdown
|
|
@@ -188,14 +188,30 @@ module Yamine
|
|
|
188
188
|
|
|
189
189
|
private
|
|
190
190
|
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
# The proxy is the process the browser's TLS stack actually talks to,
|
|
192
|
+
# so it is where the CA has to be trusted — as root, into the System
|
|
193
|
+
# keychain silently, and as a normal user, into the login keychain.
|
|
194
|
+
# A non-elevated proxy used to skip this entirely, which is how a
|
|
195
|
+
# first run as an ordinary user installed a CA that could not work.
|
|
196
|
+
#
|
|
197
|
+
# `Trust.trusted?` asks the trust store, not just the state-dir
|
|
198
|
+
# marker, so a CA that is present but untrusted is repaired instead
|
|
199
|
+
# of being short-circuited. Trust.trust only writes the marker once
|
|
200
|
+
# the trust setting is confirmed, so a failure is retried (and
|
|
201
|
+
# reported) on the next boot rather than cached as a success.
|
|
202
|
+
#
|
|
203
|
+
# YAMINE_SKIP_CA_TRUST is for machines whose CA arrives some other
|
|
204
|
+
# way — an MDM profile, a hand-run `security add-trusted-cert` — and
|
|
205
|
+
# for the suite, which spawns real proxies and must never reach a real
|
|
206
|
+
# keychain.
|
|
207
|
+
def ensure_ca_trust
|
|
208
|
+
return if ENV["YAMINE_SKIP_CA_TRUST"]
|
|
209
|
+
return if Trust.trusted?(@state_dir)
|
|
196
210
|
|
|
197
211
|
result = Trust.trust(@state_dir)
|
|
198
|
-
|
|
212
|
+
return if result[:trusted]
|
|
213
|
+
|
|
214
|
+
@on_error.call("CA trust warning: #{result[:error]}")
|
|
199
215
|
end
|
|
200
216
|
|
|
201
217
|
def admit?
|
|
@@ -313,6 +329,13 @@ module Yamine
|
|
|
313
329
|
keep, reason = forward(sock, entry, method, target, headers, buf)
|
|
314
330
|
end
|
|
315
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
|
+
|
|
316
339
|
if reason
|
|
317
340
|
render_bad_gateway(sock, entry, host, reason)
|
|
318
341
|
break
|
|
@@ -328,7 +351,7 @@ module Yamine
|
|
|
328
351
|
|
|
329
352
|
# One attempt at the backend: dial, forward the request, relay the
|
|
330
353
|
# response. Returns [keep_alive?, reason], where reason is nil on
|
|
331
|
-
# success and otherwise names which
|
|
354
|
+
# success and otherwise names which failure this was. That one
|
|
332
355
|
# value decides both whether a retry is safe and what the 502 says, so
|
|
333
356
|
# "refused" and "silent" can never drift apart again.
|
|
334
357
|
#
|
|
@@ -339,8 +362,8 @@ module Yamine
|
|
|
339
362
|
begin
|
|
340
363
|
backend = dial(entry)
|
|
341
364
|
rescue SystemCallError => e
|
|
342
|
-
@on_error.call("dial failed for #{entry["hostname"]}: #{e.message}")
|
|
343
|
-
return [:close,
|
|
365
|
+
@on_error.call("dial failed for #{entry["hostname"]}: #{e.class}: #{e.message}")
|
|
366
|
+
return [:close, failure_reason(e)]
|
|
344
367
|
end
|
|
345
368
|
|
|
346
369
|
begin
|
|
@@ -354,15 +377,29 @@ module Yamine
|
|
|
354
377
|
end
|
|
355
378
|
end
|
|
356
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
|
+
|
|
357
393
|
# A refused dial never reached the app, so any method replays safely —
|
|
358
394
|
# the request body is still unread on the client socket. A head
|
|
359
|
-
# timeout
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
# 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.
|
|
363
400
|
def retriable?(reason, method, headers)
|
|
364
401
|
return true if reason == :refused
|
|
365
|
-
return false unless
|
|
402
|
+
return false unless [:silent, :exhausted].include?(reason)
|
|
366
403
|
return false unless %w[GET HEAD OPTIONS].include?(method.to_s.upcase)
|
|
367
404
|
|
|
368
405
|
request_body_length(headers) == 0
|
|
@@ -397,17 +434,43 @@ module Yamine
|
|
|
397
434
|
return [:close, :gone] if rhead.nil?
|
|
398
435
|
|
|
399
436
|
_rm, _rt, rheaders = parse_head(rhead)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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]
|
|
408
473
|
end
|
|
409
|
-
remaining = rlen - rbuf.bytesize
|
|
410
|
-
copy_stream(backend, sock, remaining) if remaining > 0
|
|
411
474
|
|
|
412
475
|
if keep_alive?(headers) && keep_alive?(rheaders)
|
|
413
476
|
# Any bytes beyond Content-Length on the backend are a second
|
|
@@ -767,8 +830,7 @@ module Yamine
|
|
|
767
830
|
# command to run. Modeled on render_not_found, which already answers
|
|
768
831
|
# "nobody knows what this hostname is" properly.
|
|
769
832
|
def render_bad_gateway(sock, entry, host, reason = :refused)
|
|
770
|
-
|
|
771
|
-
headers = { ERROR_HEADER => kind }
|
|
833
|
+
headers = { ERROR_HEADER => error_kind(reason) }
|
|
772
834
|
bare = Hostname.strip_port(host)
|
|
773
835
|
# Same DNS-rebinding boundary as render_not_found: a Host outside our
|
|
774
836
|
# TLDs is a website that got us to answer, not the local developer
|
|
@@ -777,16 +839,31 @@ module Yamine
|
|
|
777
839
|
|
|
778
840
|
target = entry ? entry["target"].to_s : ""
|
|
779
841
|
body = "<h1>Bad Gateway</h1><p>#{what_happened(reason, target)}</p>" \
|
|
780
|
-
"#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry)}"
|
|
842
|
+
"#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry, reason)}"
|
|
781
843
|
respond(sock, 502, body, headers: headers)
|
|
782
844
|
rescue IOError, SystemCallError
|
|
783
845
|
nil
|
|
784
846
|
end
|
|
785
847
|
|
|
786
|
-
#
|
|
787
|
-
#
|
|
788
|
-
#
|
|
789
|
-
#
|
|
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.
|
|
790
867
|
def what_happened(reason, target)
|
|
791
868
|
at = target.empty? ? "" : " at <code>#{escape(target)}</code>"
|
|
792
869
|
case reason
|
|
@@ -796,6 +873,12 @@ module Yamine
|
|
|
796
873
|
when :gone
|
|
797
874
|
"The backend#{at} accepted the connection, then closed it without " \
|
|
798
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."
|
|
799
882
|
else
|
|
800
883
|
"Nothing is listening#{at}."
|
|
801
884
|
end
|
|
@@ -816,7 +899,14 @@ module Yamine
|
|
|
816
899
|
"<p>The backend for <strong>#{escape(host)}</strong> is registered#{by}#{where}.</p>"
|
|
817
900
|
end
|
|
818
901
|
|
|
819
|
-
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
|
+
|
|
820
910
|
dir = entry&.dig("spec", "dir")
|
|
821
911
|
return "<p>Start it with <code>yamine start</code> in that app's directory.</p>" unless dir
|
|
822
912
|
|
data/lib/yamine/trust.rb
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
4
5
|
|
|
5
6
|
module Yamine
|
|
6
7
|
# Install the local CA into the OS trust store.
|
|
7
8
|
module Trust
|
|
9
|
+
SYSTEM_KEYCHAIN = "/Library/Keychains/System.keychain"
|
|
10
|
+
|
|
11
|
+
# The policies a browser's TLS stack evaluates a leaf under: SSL
|
|
12
|
+
# server (the hostname check) and X.509 basic (the chain build).
|
|
13
|
+
#
|
|
14
|
+
# They have to be named, and this is measured, not assumed.
|
|
15
|
+
# `add-trusted-cert -r trustRoot` with no `-p` exits 0 and records
|
|
16
|
+
# the certificate in the trust store with NO `trustSettings` array at
|
|
17
|
+
# all: `security dump-trust-settings` reports "Number of trust
|
|
18
|
+
# settings : 0", and `security trust-settings-export` shows a
|
|
19
|
+
# trustList entry holding only issuerName/modDate/serialNumber. What
|
|
20
|
+
# the CA is trusted FOR is then unstated, and whether such an entry
|
|
21
|
+
# is honoured at all depends on the certificate also being installed
|
|
22
|
+
# in a keychain the evaluator searches — on the machine this was
|
|
23
|
+
# fixed on, it was not, and every browser answered
|
|
24
|
+
# ERR_CERT_AUTHORITY_INVALID.
|
|
25
|
+
#
|
|
26
|
+
# With `-p ssl -p basic` the same call records
|
|
27
|
+
# kSecTrustSettingsPolicyName sslServer + basicX509, and per
|
|
28
|
+
# SecTrustSettings.h a settings entry with no explicit
|
|
29
|
+
# kSecTrustSettingsResult defaults to kSecTrustSettingsResultTrustRoot
|
|
30
|
+
# ("trust this root cert"). That is also what Keychain Access writes
|
|
31
|
+
# for a certificate set to Secure Sockets Layer + X.509 Basic.
|
|
32
|
+
MACOS_TRUST_POLICIES = %w[-p ssl -p basic].freeze
|
|
33
|
+
|
|
34
|
+
# SecTrustSettingsResult (Security/SecTrustSettings.h). Only these
|
|
35
|
+
# two grant trust; 3 is Deny and 4 is Unspecified, so a CA whose only
|
|
36
|
+
# setting is one of those is explicitly NOT trusted.
|
|
37
|
+
TRUST_RESULTS = [1, 2].freeze
|
|
38
|
+
|
|
8
39
|
module_function
|
|
9
40
|
|
|
10
41
|
def platform
|
|
@@ -31,6 +62,33 @@ module Yamine
|
|
|
31
62
|
{ trusted: false, error: e.message }
|
|
32
63
|
end
|
|
33
64
|
|
|
65
|
+
# True when the OS trust store will honour the CA in `dir`.
|
|
66
|
+
#
|
|
67
|
+
# The state-dir marker is only half the answer, and the half that
|
|
68
|
+
# lies: it records that WE trusted this exact certificate at some
|
|
69
|
+
# point, so it survives a keychain that never took the setting, a
|
|
70
|
+
# trust run that recorded nothing, and a CA that was regenerated
|
|
71
|
+
# under it. Treating the marker as "trusted" is how a machine kept a
|
|
72
|
+
# CA in its keychain that no browser would accept.
|
|
73
|
+
def trusted?(dir = Certs.state_dir)
|
|
74
|
+
return false unless Certs.trusted?(dir)
|
|
75
|
+
return true unless platform == :macos
|
|
76
|
+
|
|
77
|
+
paths = Certs.ca_paths(dir)
|
|
78
|
+
return false unless File.file?(paths[:cert])
|
|
79
|
+
|
|
80
|
+
macos_keychains.any? { |keychain| already_trusted?(paths[:cert], keychain) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The keychains a CA can be trusted into: the user's own, and — for
|
|
84
|
+
# an elevated run — the System one, which every user on the machine
|
|
85
|
+
# reads. Both are checked because the certificate on disk may have
|
|
86
|
+
# been trusted into either, and a CA trusted only as root is not
|
|
87
|
+
# trusted for a later unprivileged run (or the reverse).
|
|
88
|
+
def macos_keychains
|
|
89
|
+
[login_keychain, SYSTEM_KEYCHAIN].uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
34
92
|
# The fingerprint `security -Z` prints: uppercase hex, no colons.
|
|
35
93
|
# Certificates are identified by THIS, never by common name: every CA
|
|
36
94
|
# this project has generated shares a name with the others, so a
|
|
@@ -81,7 +139,7 @@ module Yamine
|
|
|
81
139
|
end
|
|
82
140
|
|
|
83
141
|
def keychains
|
|
84
|
-
[login_keychain,
|
|
142
|
+
[login_keychain, SYSTEM_KEYCHAIN]
|
|
85
143
|
end
|
|
86
144
|
|
|
87
145
|
# Remove trusted certificates that carry one of our CA names but are
|
|
@@ -206,25 +264,142 @@ module Yamine
|
|
|
206
264
|
return if already_trusted?(cert_path, keychain)
|
|
207
265
|
|
|
208
266
|
_out, status = Command.capture2("security", "add-trusted-cert",
|
|
209
|
-
"-d", "-r", "trustRoot", "-k", keychain, cert_path)
|
|
210
|
-
raise CertError,
|
|
267
|
+
"-d", "-r", "trustRoot", *MACOS_TRUST_POLICIES, "-k", keychain, cert_path)
|
|
268
|
+
raise CertError, macos_trust_error(cert_path, keychain) unless status.success?
|
|
211
269
|
else
|
|
270
|
+
# Not elevated: the user's own keychain, with the trust policies
|
|
271
|
+
# named. The certificate still has to be in a keychain — macOS
|
|
272
|
+
# builds the chain by searching keychains for the issuer, so a
|
|
273
|
+
# trust setting for a certificate that is not installed anywhere
|
|
274
|
+
# is dead weight. Measured: CA trusted but absent from every
|
|
275
|
+
# keychain => "unable to get local issuer certificate".
|
|
212
276
|
keychain = login_keychain
|
|
213
277
|
return if already_trusted?(cert_path, keychain)
|
|
214
278
|
|
|
215
279
|
_out, status = Command.capture2("security", "add-trusted-cert",
|
|
216
|
-
"-r", "trustRoot", "-k", keychain, cert_path)
|
|
217
|
-
raise CertError,
|
|
280
|
+
"-r", "trustRoot", *MACOS_TRUST_POLICIES, "-k", keychain, cert_path)
|
|
281
|
+
raise CertError, macos_trust_error(cert_path, keychain) unless status.success?
|
|
218
282
|
end
|
|
283
|
+
|
|
284
|
+
# `add-trusted-cert` exits 0 for a certificate it merely filed —
|
|
285
|
+
# which is exactly what the policy-less invocation did on every
|
|
286
|
+
# non-elevated install. The exit status is not evidence of trust,
|
|
287
|
+
# only the trust store is: re-read it here, or the state-dir marker
|
|
288
|
+
# records a success no browser will ever agree with.
|
|
289
|
+
return if already_trusted?(cert_path, keychain)
|
|
290
|
+
|
|
291
|
+
raise CertError, macos_trust_error(cert_path, keychain)
|
|
219
292
|
end
|
|
220
293
|
|
|
221
|
-
|
|
222
|
-
|
|
294
|
+
def macos_trust_error(cert_path, keychain)
|
|
295
|
+
domain = (keychain == "/Library/Keychains/System.keychain") ? ["-d"] : []
|
|
296
|
+
command = (["security", "add-trusted-cert", *domain, "-r", "trustRoot",
|
|
297
|
+
*MACOS_TRUST_POLICIES, "-k", keychain, cert_path]).join(" ")
|
|
298
|
+
"macOS recorded no trust setting for the CA in #{keychain}. " \
|
|
299
|
+
"Writing user trust settings needs authorization, which a detached " \
|
|
300
|
+
"or headless run cannot grant — run this in a terminal, then retry: " \
|
|
301
|
+
"#{command}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# True when macOS will actually honour this certificate as a CA.
|
|
305
|
+
#
|
|
306
|
+
# BOTH halves are required, and the second one is the one that used
|
|
307
|
+
# to be missing:
|
|
308
|
+
#
|
|
309
|
+
# 1. the certificate is in the keychain, so the trust evaluator can
|
|
310
|
+
# find the issuer when it builds the chain; and
|
|
311
|
+
# 2. the trust store records a trust SETTING for its fingerprint.
|
|
312
|
+
#
|
|
313
|
+
# Presence alone is not trust: a certificate in a keychain with no
|
|
314
|
+
# trust setting fails with CSSMERR_TP_NOT_TRUSTED. Neither is a bare
|
|
315
|
+
# trust-list entry — the key with no `trustSettings` array that
|
|
316
|
+
# `add-trusted-cert` without `-p` leaves behind, which
|
|
317
|
+
# `security dump-trust-settings` counts as zero settings. Requiring
|
|
318
|
+
# a real setting costs one corrective add on a machine that has the
|
|
319
|
+
# bare kind (the add upgrades it in place, without duplicating the
|
|
320
|
+
# certificate) and nothing thereafter, so the stricter reading
|
|
321
|
+
# converges rather than looping.
|
|
322
|
+
#
|
|
323
|
+
# The fingerprint comparison stays: adding a certificate twice is not
|
|
324
|
+
# harmless, and it is how the trust store filled up with duplicates.
|
|
325
|
+
# It is now a necessary condition rather than the whole answer.
|
|
223
326
|
def already_trusted?(cert_path, keychain)
|
|
224
327
|
fp = fingerprint_of(cert_path)
|
|
225
328
|
return false unless fp
|
|
329
|
+
return false unless keychain_fingerprints(keychain, common_name: nil).include?(fp)
|
|
226
330
|
|
|
227
|
-
|
|
331
|
+
trust_setting?(fp, keychain)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# The trust settings the OS records, as plist XML, or nil when macOS
|
|
335
|
+
# cannot be asked at all.
|
|
336
|
+
#
|
|
337
|
+
# The trust store is keyed by the certificate's SHA-1 fingerprint —
|
|
338
|
+
# the same identity `security -Z` prints — so this never has to match
|
|
339
|
+
# on a common name, which every yamine CA shares.
|
|
340
|
+
#
|
|
341
|
+
# `security trust-settings-export` is the only `security` subcommand
|
|
342
|
+
# that reports the SETTING rather than the presence:
|
|
343
|
+
# `dump-trust-settings` lists certificates and a count of their
|
|
344
|
+
# settings, `find-certificate` lists certificates. The admin domain
|
|
345
|
+
# needs `-d`; without it only the user's own settings are exported,
|
|
346
|
+
# so a root install would look untrusted forever.
|
|
347
|
+
#
|
|
348
|
+
# The certificate's own key is looked up by the caller, so a store
|
|
349
|
+
# that holds no entry for it comes back as XML WITHOUT it — that is a
|
|
350
|
+
# definite answer (never trusted), not an unreadable one.
|
|
351
|
+
def trust_settings(keychain)
|
|
352
|
+
file = File.join(Dir.tmpdir, "yamine-trust-#{Process.pid}-#{rand(1 << 32)}.plist")
|
|
353
|
+
_out, status = Command.capture2("security", "trust-settings-export",
|
|
354
|
+
*("-d" if keychain == SYSTEM_KEYCHAIN), file)
|
|
355
|
+
return nil unless status.success?
|
|
356
|
+
|
|
357
|
+
# Read back through plutil rather than parsing the plist here: it
|
|
358
|
+
# is Apple's own reader, and it keeps this file free of a plist
|
|
359
|
+
# parser (rexml is a bundled gem, unavailable under bundler).
|
|
360
|
+
xml, converted = Command.capture2("plutil", "-convert", "xml1", "-o", "-", file)
|
|
361
|
+
converted.success? ? xml : nil
|
|
362
|
+
rescue SystemCallError
|
|
363
|
+
nil
|
|
364
|
+
ensure
|
|
365
|
+
File.unlink(file) if file && File.file?(file)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# True when the trust store records a trust SETTING that grants
|
|
369
|
+
# trust for this certificate.
|
|
370
|
+
#
|
|
371
|
+
# Returns TRUE when the trust store cannot be read. That direction
|
|
372
|
+
# is deliberate: a check that always answers "not trusted" turns
|
|
373
|
+
# every boot into a re-add, and a re-add in the user domain can
|
|
374
|
+
# raise a GUI authorization prompt — a worse failure than the one
|
|
375
|
+
# this fixes.
|
|
376
|
+
def trust_setting?(fingerprint, keychain)
|
|
377
|
+
settings = trust_settings(keychain)
|
|
378
|
+
return true if settings.nil?
|
|
379
|
+
|
|
380
|
+
entry = plist_dict_after(settings, fingerprint)
|
|
381
|
+
return false unless entry&.include?("<key>trustSettings</key>")
|
|
382
|
+
|
|
383
|
+
granted = entry.scan(%r{<key>kSecTrustSettingsResult</key>\s*<integer>(\d+)</integer>}).flatten
|
|
384
|
+
return true if granted.empty? # policies only: defaults to trustRoot
|
|
385
|
+
|
|
386
|
+
granted.any? { |result| TRUST_RESULTS.include?(result.to_i) }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The `<dict>` that follows `key`, nested dicts included. A
|
|
390
|
+
# trustSettings array is a list of dicts, so a lazy `.*?</dict>`
|
|
391
|
+
# would stop in the middle of the very array being read.
|
|
392
|
+
def plist_dict_after(xml, key)
|
|
393
|
+
at = xml.index("<key>#{key}</key>")
|
|
394
|
+
return nil unless at
|
|
395
|
+
|
|
396
|
+
rest = xml[(at + 1)..]
|
|
397
|
+
depth = 0
|
|
398
|
+
rest.scan(/<dict>|<\/dict>/) do
|
|
399
|
+
depth += ($~[0] == "<dict>" ? 1 : -1)
|
|
400
|
+
return rest[0...$~.end(0)] if depth.zero?
|
|
401
|
+
end
|
|
402
|
+
nil
|
|
228
403
|
end
|
|
229
404
|
|
|
230
405
|
def login_keychain
|
data/lib/yamine/version.rb
CHANGED