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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69848a3d38cd9997ca18e52ea892aa57962524e0cd41df16199a651409e86b76
4
- data.tar.gz: aebe3dd18c4bab95865491850bb940ca9a7e42c83f53d851b4d3ddd691f80305
3
+ metadata.gz: 308817ac04ecc72d2d0af8ca1a9262d502d5dfb27d4b21a11403c12eebac4aa0
4
+ data.tar.gz: 0d8c7fdc05ef6bb45ca01165901f14d9d9d17b80e4e86d1c32614fb8f12594a0
5
5
  SHA512:
6
- metadata.gz: 7c1bb5ef1104c29a53c8e4274a5b4a42f7231f895f40bb7d12defc97dee72232944340c24b4bec62b9a736d29bdab01d975b32cf6e1af7aaf7f1b643ec0ac602
7
- data.tar.gz: 66ef5d87789c27a94994c0d24f1567c1a762d592ecbfb00b3d95db8a7f538f3c6868be3703a9870a88d0101aabb37dcfaba8f6db85b2e4a74799b4a4381c7280
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
@@ -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
@@ -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. `trusted?` compares a marker against the
521
- # fingerprint of the certificate ON DISK, and a proxy regenerates
522
- # the CA at boot when the name changes. Checking the marker before
523
- # the CA is current therefore reads a stale match, skips trust, and
524
- # lets the proxy come up serving a CA the keychain does not trust —
525
- # breaking TLS for every route.
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
- Certs.trusted?(dir)
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
- 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/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
- ensure_system_ca_trust if Process.uid.zero?
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
- # A root proxy (launchd service or sudo daemon) is the one process
192
- # that can trust the CA into the System keychain silently — no GUI
193
- # popup. Idempotent: the state-dir marker makes repeat boots a no-op.
194
- def ensure_system_ca_trust
195
- return if Certs.trusted?(@state_dir)
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
- @on_error.call("CA trust warning: #{result[:error]}") unless result[:trusted]
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 backend failure this was. That one
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, :refused]
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 means the head DID reach the app, so the only safe replays
360
- # are the bodiless methods: the body has already been consumed off the
361
- # client socket and cannot be faithfully resent, and a duplicated
362
- # 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.
363
400
  def retriable?(reason, method, headers)
364
401
  return true if reason == :refused
365
- return false unless reason == :silent
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
- write_all(sock, rhead)
401
- write_all(sock, rbuf) unless rbuf.empty?
402
-
403
- rlen = content_length(rheaders)
404
- if rlen.nil?
405
- # No Content-Length: close-delimited response.
406
- copy_stream(backend, sock)
407
- 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]
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
- kind = reason == :silent ? "backend-silent" : "backend-refused"
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
- # Which of the two failures this was, said the way each one has to be
787
- # read: not listening means start the app, silent means go find out why
788
- # an app that is up is not answering. One "not responding" for both
789
- # 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.
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, "/Library/Keychains/System.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, "security add-trusted-cert (system) failed" unless status.success?
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, "security add-trusted-cert failed" unless status.success?
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
- # Adding the same certificate twice is not harmless: it is how the
222
- # trust store accumulated duplicates across repeated setup runs.
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
- keychain_fingerprints(keychain, common_name: nil).include?(fp)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.22.0"
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.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto