yamine 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f7d162f5c1ad28d48339119a7a5f6c15e1d1b269a8e33a0a6f21b51d24be07f
4
- data.tar.gz: 795408acb99c284062307bd7e08217af3b1806ccb0d4b6b47c2b0841e0439a83
3
+ metadata.gz: 73d2fe319b2c8af2c4bf821285dc76af42705e532761a8b2351377696928a114
4
+ data.tar.gz: 5a92b4760142642deed0a5a0131536094ac553b79759924daa7cc2157dec90a6
5
5
  SHA512:
6
- metadata.gz: 05f6fc345aa7a2f2d92194d1c3095b41b16c15b3be3bfc9d5e030eb84b8b589de349129da286242abf41af3a956999946a9f6b88edc34ab87dd8424e17ae06fd
7
- data.tar.gz: c33864d1addccc46714f86556310fcd19cad835525d1b3eb800d7badc5987177406f924dcc27e77b04d4363e014042f997b0b04c5a020931e8e0d0d187687b94
6
+ metadata.gz: 859e65d51d8e417cb1c747cf2d95a8336d4266fd57347d611ca3744baf4538dde86190fc5bc46023cbf28a0c12faceb7d2e3f84895afd62617e2c78e7fd1fc46
7
+ data.tar.gz: 8fc48c3d08d4ef3dfb5f579f41583ce3f315c90e896289279b072a5f9e6d66013eaa2b02f66eeb8ce3e983d2d4664287aa3e868e33606bc4f687f7975cd9c25a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,138 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.21.0] — 2026-09-28
6
+
7
+ ### Security
8
+
9
+ - **The privileged service no longer runs user-writable code.** The
10
+ launchd/systemd unit used to execute the gem's `bin`+`lib` straight
11
+ out of the user's home (a version-stamped, user-writable gem
12
+ directory), so anything that could write those paths — a gem upgrade,
13
+ a `bundle install`, an agent editing the gem — ran as root at every
14
+ boot. `service install` now stages `bin`+`lib` into a root-owned
15
+ directory at a stable, version-independent path (`/Library/Application
16
+ Support/yamine` on macOS, `/usr/local/share/yamine` on Linux):
17
+ copied aside, ownership/modes locked, verified fail-closed (a bad
18
+ tree registers no unit and leaves the running daemon untouched), then
19
+ swung live with an atomic symlink swap plus a root-owned `VERSION`
20
+ marker. The unit pins only that path.
21
+ - **The passwordless grant can no longer introduce root-executed
22
+ code.** `yamine sudoers` used to grant the version-stamped gem path
23
+ with `service install --internal` — effectively arbitrary code as
24
+ root for anyone who could write the gem, and stale after every
25
+ release. It now grants only the staged, version-independent payload:
26
+ `hosts sync` (the recurring agent need) and `service uninstall`
27
+ (which only deletes yamine's own files). Staging new root code stays
28
+ a human interactive sudo; a non-interactive `service install` fails
29
+ fast saying so. If your `/etc/sudoers.d/yamine` still pins a
30
+ version-stamped gem path, re-run `yamine sudoers` and replace it.
31
+ - **`hosts sync` is strictly validated before anything is written.**
32
+ Route names are user-writable and the sync runs as root, so every
33
+ name must now be well-formed (no newline/breakout), written only
34
+ inside yamine's managed block, and under `.localhost` or a domain in
35
+ the root-owned staged allowlist. Anything else fails closed naming
36
+ the policy. Custom `proxy.host` domains need one human step: append
37
+ the parent domain to the staged `allowed-tlds` file.
38
+ - **`yamine doctor` gains a `service` check.** It names a legacy unit
39
+ (payload in a user home or version-stamped gem path) with the exact
40
+ migration (`sudo yamine service install`), warns on staged-vs-CLI
41
+ version skew, fails on any root-executed path that is not root-owned
42
+ or is group/other-writable, and always reports the service
43
+ interpreter's path and writability. The legacy daemon keeps working
44
+ until the next elevated install.
45
+
46
+ ### Changed
47
+
48
+ - `service install` (human, interactive, elevated) migrates a legacy
49
+ install in place and prints the interpreter caveat; `service
50
+ uninstall` removes the staged payload as well as the unit, scoped to
51
+ yamine's own files.
52
+ - Non-interactive (`sudo -n`) failure messaging points at the new
53
+ provisioning story: humans install the 443 service once per machine,
54
+ agents keep hosts entries fresh through the grant.
55
+
56
+ ### Fixed
57
+
58
+ - **The printed grant now matches what the CLI invokes, byte for
59
+ byte.** Elevated re-execs went through `env YAMINE_STATE_DIR=…`,
60
+ which made the command `/usr/bin/env` — a path no rule names — so
61
+ every passwordless attempt fell through to the password-required
62
+ admin rule; and the staged macOS path's space was unescaped in the
63
+ sudoers output, so that rule could never match either. No
64
+ environment crosses sudo anymore (no `env` prefix, no SETENV): the
65
+ root half derives the invoking user's state dir from `SUDO_USER`,
66
+ and spaces are backslash-escaped when the rules are printed. Pinned
67
+ by a parity test that parses the printed specs and asserts equality
68
+ with the captured elevated argv.
69
+ - **The `--no-service` sudo daemon no longer executes the gem
70
+ directory as root.** A privileged `proxy start` spawns the staged
71
+ root-owned payload, staging it first under the same
72
+ human-authorized sudo (`service stage --internal`, never granted)
73
+ when missing. The unprivileged spawn path is unchanged.
74
+
75
+ ### Open caveat (not closed, stated plainly)
76
+ - **The interpreter is still user-writable.** No root-owned Ruby ≥ 3.2
77
+ exists on a stock machine, so the daemon necessarily runs the
78
+ invoking (user-writable) Ruby. The payload is root-owned; the
79
+ interpreter is not. Doctor reports it truthfully and the README says
80
+ so. Fixed only by a root-owned Ruby new enough for the gem; yamine
81
+ will not vendor or stage an interpreter.
82
+
83
+ ## [0.20.0] — 2026-09-28
84
+
85
+ ### Added
86
+
87
+ - **Every proxy socket wait is bounded by an idle timeout
88
+ (`YAMINE_PROXY_IDLE_TIMEOUT`, default 60s).** Silence is capped,
89
+ total duration is not: the timer resets on each byte transferred, so
90
+ a slow-but-moving transfer runs as long as it needs while a stalled
91
+ peer or a stale keep-alive socket is dropped instead of pinning a
92
+ thread until the proxy stops answering.
93
+ - **`yamine doctor` gains a "serving ca" check.** It verifies the
94
+ certificate the proxy actually serves against the CA now on disk and
95
+ names the fix when they disagree — so a regenerated CA shows up in
96
+ doctor instead of surfacing later as `ERR_CERT_AUTHORITY_INVALID` in
97
+ the browser.
98
+
99
+ ### Changed
100
+
101
+ - **`yamine proxy start` no longer spawns a second proxy when one
102
+ already serves the port.** It reports that idempotently (new
103
+ `ProxyAlreadyRunningError`) instead of recording a dead PID over
104
+ real state — the shape that left the recorded PID pointing at
105
+ nothing while the real proxy kept serving.
106
+ - **`yamine proxy start` on a privileged port elevates the way the
107
+ boot path does.** One sudo prompt when interactive, or a failure
108
+ pointing at `yamine setup` when not — never a doomed unprivileged
109
+ child that dies on `EACCES` and leaves its PID behind.
110
+ - **`yamine proxy stop` tells the truth about a proxy it cannot
111
+ signal.** On a dead recorded PID it probes the port first: a proxy
112
+ still serving — the root launchd/systemd service, which the CLI
113
+ cannot kill — yields a "still serving" answer with the restart
114
+ command and the recorded state kept, instead of "Removed stale
115
+ proxy state" while the proxy kept running.
116
+ - **`yamine clean` refuses while a proxy it cannot stop is serving.**
117
+ It exits 1 instead of untrusting the CA and deleting the state
118
+ directory out from under a live root service.
119
+ - **A running proxy reloads the signing CA when the pair on disk
120
+ changes.** It re-reads the CA files and drops cached host
121
+ certificates, so a regenerated CA takes effect without a restart —
122
+ previously only a restart fixed it, and until then the proxy kept
123
+ signing certificates no browser trusts.
124
+ - **The TLS handshake moved out of the acceptor into the connection
125
+ thread.** A client that connects and sends nothing can no longer pin
126
+ an acceptor; acceptors now survive per-connection errors and keep
127
+ accepting.
128
+
129
+ ### Fixed
130
+
131
+ - **A spawned daemon that dies before serving fails immediately with
132
+ the proxy log tail instead of a timeout.** A child killed by
133
+ `EACCES` on a privileged port (or anything else fatal at bind)
134
+ used to burn the whole wait and then record a PID that was never
135
+ alive; it now fails fast and records nothing.
136
+
5
137
  ## [0.19.0] — 2026-09-27
6
138
 
7
139
  ### Added
data/README.md CHANGED
@@ -54,21 +54,35 @@ to get back to 443.
54
54
 
55
55
  Binding 443 is privileged, so yamine installs a **root-owned launchd
56
56
  service** (macOS) or systemd unit (Linux) that binds 443 at boot — the
57
- same model as puma-dev and portless. Installing it needs sudo **once per
58
- machine**; after that, every `yamine` run in any project gets a clean
59
- `https://<app>.localhost` with no elevation and no prompt.
57
+ same model as puma-dev and portless. Installing it needs an interactive
58
+ sudo **once per machine** (one Touch ID tap); after that, every `yamine`
59
+ run in any project gets a clean `https://<app>.localhost` with no
60
+ elevation and no prompt.
60
61
 
61
- **Human (interactive):** run setup once — it trusts the CA, installs the
62
- service, syncs hosts, and verifies:
62
+ **Human (interactive):** run setup once — it stages a root-owned copy of
63
+ yamine, installs the service, trusts the CA, syncs hosts, and verifies:
63
64
 
64
65
  ```bash
65
66
  yamine setup
67
+ # or, for just the service: sudo yamine service install
66
68
  ```
67
69
 
68
- **Agent / CI (no TTY):** the same commands fail fast with guidance,
69
- because sudo needs a terminal. To pre-provision a machine or image so
70
- agents can install the service without a prompt, install the scoped
71
- passwordless-sudo rules once (as an admin):
70
+ The unit runs a **staged payload**, not your gem directory: the install
71
+ copies yamine's `bin`+`lib` into a root-owned directory at a stable,
72
+ version-independent path (`/Library/Application Support/yamine` on
73
+ macOS, `/usr/local/share/yamine` on Linux), verifies root ownership and
74
+ modes fail-closed (a bad tree registers nothing), and pins that path in
75
+ the unit. Ordinary user-space writes — a gem upgrade, a `bundle
76
+ install`, an agent editing the gem — can no longer change what runs as
77
+ root, and upgrades never stale the setup. `yamine doctor` names a legacy
78
+ install (a unit still pointing at a user-writable gem path) with the
79
+ exact migration, plus staged-vs-CLI version skew and any ownership
80
+ problem.
81
+
82
+ **Agents: the steady-state grant.** New project ⇒ new hostname ⇒ Safari
83
+ needs the `/etc/hosts` entry; that recurring privileged need stays
84
+ agent-invocable without a prompt. Install the scoped passwordless-sudo
85
+ rules once (as an admin):
72
86
 
73
87
  ```bash
74
88
  yamine sudoers > /tmp/yamine.sudoers
@@ -76,11 +90,41 @@ sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine
76
90
  sudo install -o root -g root -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine # Linux
77
91
  ```
78
92
 
79
- `yamine sudoers` prints rules scoped to yamine's own service
80
- re-exec — the gem's exact ruby + bin path with the `service install
81
- --internal` / `service uninstall --internal` subcommands — never a bare
82
- interpreter. Re-run it after upgrading the gem if the install path
83
- changes. To undo: `sudo rm /etc/sudoers.d/yamine`.
93
+ `yamine sudoers` prints rules for the **staged payload only** — `hosts
94
+ sync` (hostnames strictly validated: well-formed, written only inside
95
+ yamine's managed block, and only under `.localhost` or an allowlisted
96
+ domain) and `service uninstall` (which only deletes yamine's own
97
+ files). Installing or upgrading the staged payload moves user-writable
98
+ source into root-owned paths, so it deliberately stays a human
99
+ interactive sudo — no grant will ever cover it, and a non-interactive
100
+ `service install` fails fast saying so. To undo the grant:
101
+ `sudo rm /etc/sudoers.d/yamine`.
102
+
103
+ If you installed an older yamine, your `/etc/sudoers.d/yamine` may still
104
+ pin a version-stamped gem path (`.../gems/yamine-X.Y.Z/...`) including
105
+ an install rule — re-run `yamine sudoers` and replace the file. The old
106
+ rules go stale every release (which pushed people toward `NOPASSWD:
107
+ ALL`); the new ones survive upgrades.
108
+
109
+ **Custom domains** (e.g. `proxy.host: myapp.local.example.com` for OAuth
110
+ parity) need their parent domain allowlisted once by a human — `hosts
111
+ sync` refuses anything outside `.localhost` and the staged allowlist,
112
+ because an unvalidated root hosts write could point a real vendor domain
113
+ at loopback:
114
+
115
+ ```bash
116
+ echo 'local.example.com' | sudo tee -a "/Library/Application Support/yamine/allowed-tlds" # macOS
117
+ echo 'local.example.com' | sudo tee -a /usr/local/share/yamine/allowed-tlds # Linux
118
+ ```
119
+
120
+ **Open caveat: the interpreter.** The unit runs the invoking Ruby, and
121
+ there is no root-owned Ruby ≥ 3.2 on a stock machine (`/usr/bin/ruby` is
122
+ 2.6), so the daemon necessarily runs a user-writable interpreter today.
123
+ The payload is root-owned; the interpreter is not — `yamine doctor`
124
+ reports its path and writability truthfully. Anything that can write
125
+ that Ruby can change what runs as root. This hole stays open until the
126
+ machine has a root-owned Ruby new enough for the gem; yamine will not
127
+ vendor or stage an interpreter to pretend otherwise.
84
128
 
85
129
  ## The one-file model
86
130
 
data/lib/yamine/certs.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
4
5
  require "openssl"
5
6
 
@@ -170,23 +171,32 @@ module Yamine
170
171
  end
171
172
 
172
173
  # Build an SSLContext whose SNI callback serves the right cert per host.
174
+ #
175
+ # The CA pair on disk can be regenerated under a live proxy (`yamine
176
+ # clean`/`trust`/`setup` rewrite it). A proxy that keeps signing with
177
+ # its boot-time CA then mints leaves no browser trusts
178
+ # (ERR_CERT_AUTHORITY_INVALID against the new CA), so the SNI
179
+ # callback — which already runs per handshake — reloads the pair
180
+ # when it changes and drops every leaf minted from the old one.
173
181
  def server_context(dir = state_dir)
174
- ca_cert, ca_key = load_ca(dir)
182
+ watcher = CaWatcher.new(dir)
175
183
  cache = CertCache.new(CACHE_SIZE)
184
+ watcher.on_reload = -> { cache.clear }
176
185
  ctx = OpenSSL::SSL::SSLContext.new
177
- ctx.cert = ca_cert
178
- ctx.key = ca_key
186
+ ctx.cert, ctx.key = watcher.current
179
187
  # ruby-openssl versions differ in how the callback receives its
180
188
  # arguments: [[socket, name]] (one array arg), (socket, name), or
181
189
  # (name). Flatten defensively — a raise inside the callback
182
190
  # surfaces as an unrecognized-name handshake alert.
183
191
  ctx.servername_cb = lambda do |*args|
184
192
  host = Array(args).flatten.last.to_s.downcase
185
- entry = cache.fetch(host) do
186
- cert, key = mint_host(host, ca_cert, ca_key)
187
- [cert, key]
193
+ watcher.with_current do |ca_cert, ca_key|
194
+ entry = cache.fetch(host) do
195
+ cert, key = mint_host(host, ca_cert, ca_key)
196
+ [cert, key]
197
+ end
198
+ entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
188
199
  end
189
- entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
190
200
  end
191
201
  ctx
192
202
  end
@@ -252,6 +262,96 @@ module Yamine
252
262
  def size
253
263
  @mutex.synchronize { @store.size }
254
264
  end
265
+
266
+ # Every entry is signed by one CA generation; a reload must not
267
+ # leave old-CA leaves behind to be served after the swap.
268
+ def clear
269
+ @mutex.synchronize do
270
+ @store.clear
271
+ @order.clear
272
+ end
273
+ end
274
+ end
275
+
276
+ # The CA pair a live server_context signs with, reloaded when the
277
+ # files on disk change. The hot path is two stat calls per
278
+ # handshake; the pair is only re-read (and the leaf cache only
279
+ # dropped) when a stat snapshot moves AND the digest differs, so a
280
+ # touch without content change costs nothing. One mutex around
281
+ # check+reload+mint keeps a swap from racing a handshake into a
282
+ # mixed pair or a stale cache hit. A failed reload keeps serving
283
+ # the previous CA — a raise inside the SNI callback would abort
284
+ # the handshake outright.
285
+ class CaWatcher
286
+ def initialize(dir)
287
+ @dir = dir
288
+ @mutex = Mutex.new
289
+ @cert, @key = Certs.load_ca(dir)
290
+ @stat = stat_snapshot
291
+ @digest = pair_digest
292
+ @on_reload = nil
293
+ end
294
+
295
+ # Called (once per swap, under the lock) after a reload. The
296
+ # server_context sets this to drop its leaf cache.
297
+ attr_writer :on_reload
298
+
299
+ def current
300
+ [@cert, @key]
301
+ end
302
+
303
+ def with_current
304
+ @mutex.synchronize do
305
+ reload_if_changed!
306
+ yield @cert, @key
307
+ end
308
+ end
309
+
310
+ private
311
+
312
+ def paths
313
+ Certs.ca_paths(@dir)
314
+ end
315
+
316
+ def stat_snapshot
317
+ [paths[:cert], paths[:key]].map do |path|
318
+ stat = File.stat(path)
319
+ [stat.mtime, stat.size]
320
+ end
321
+ rescue SystemCallError
322
+ nil
323
+ end
324
+
325
+ def pair_digest
326
+ Digest::SHA256.hexdigest(File.read(paths[:cert]) + File.read(paths[:key]))
327
+ rescue SystemCallError, OpenSSL::OpenSSLError
328
+ nil
329
+ end
330
+
331
+ def reload_if_changed!
332
+ snapshot = stat_snapshot
333
+ return if !snapshot.nil? && snapshot == @stat
334
+
335
+ digest = pair_digest
336
+ if !digest.nil? && digest == @digest
337
+ # Touched but unchanged: adopt the snapshot so the hot path
338
+ # goes back to two stats instead of a digest per handshake.
339
+ @stat = snapshot
340
+ return
341
+ end
342
+
343
+ # The serving SSLContext is frozen once the server accepts, so
344
+ # the default certificate cannot be swapped mid-flight — and it
345
+ # does not need to be: every real client sends SNI, and the
346
+ # per-host context this callback returns carries the new CA.
347
+ cert, key = Certs.load_ca(@dir)
348
+ @cert, @key = cert, key
349
+ @stat = stat_snapshot
350
+ @digest = digest || pair_digest
351
+ @on_reload&.call
352
+ rescue CertError, OpenSSL::OpenSSLError, SystemCallError
353
+ nil
354
+ end
255
355
  end
256
356
  end
257
357
  end
@@ -940,10 +940,11 @@ module Yamine
940
940
  privileged = port < 1024 && !ProxyControl.root?
941
941
  if privileged && !ctx.interactive?
942
942
  $stderr.puts "Error: proxy is not running and port #{port} needs root."
943
- $stderr.puts " Human: run this once — yamine setup"
944
- $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
945
- $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
946
- $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
943
+ $stderr.puts " Human: run this once in a terminal — yamine setup (or: sudo yamine service install)"
944
+ $stderr.puts " Agent/CI: the 443 service is installed by a human once per machine — it cannot be provisioned passwordlessly."
945
+ $stderr.puts " Steady-state hosts sync works via the grant instead:"
946
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
947
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
947
948
  $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
948
949
  exit 1
949
950
  end