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.
@@ -8,7 +8,7 @@ module Yamine
8
8
  # enable/kickstart/bootout` address it by. Renamed from "dev.ask.local"
9
9
  # (an ask-local leftover); the old label is cleared on install so the
10
10
  # two never coexist and fight over port 443.
11
- LAUNCHD_LABEL = "dev.yamine"
11
+ LAUNCHD_LABEL = PrivilegedPayload::LAUNCHD_LABEL
12
12
  LEGACY_LAUNCHD_LABELS = %w[dev.ask.local].freeze
13
13
 
14
14
  module_function
@@ -30,7 +30,16 @@ module Yamine
30
30
  end
31
31
 
32
32
  def clean(ctx, _args)
33
- ProxyControl.stop(ctx.store)
33
+ # A root service the CLI cannot signal keeps serving past stop:
34
+ # deleting the state dir (and untrusting the CA) then would
35
+ # orphan the live proxy and break its TLS. Refuse with the fix
36
+ # instead; the state on disk still describes the live proxy.
37
+ if ProxyControl.stop(ctx.store) == :needs_root
38
+ $stderr.puts "Cannot clean: the proxy runs as root (the launchd/systemd service) and is still serving."
39
+ $stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
40
+ $stderr.puts " Or uninstall it: sudo yamine service uninstall — then re-run `yamine clean`."
41
+ exit 1
42
+ end
34
43
  result = Trust.untrust
35
44
  puts "CA removed from trust store." if result[:removed]
36
45
  warn "CA untrust failed: #{result[:error]}" if result[:error]
@@ -44,20 +53,31 @@ module Yamine
44
53
  sub = args.first
45
54
  case sub
46
55
  when "sync"
47
- hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
48
- if Hosts.sync(hostnames)
49
- puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
50
- elsif !ProxyControl.root? && !hostnames.empty?
51
- # /etc/hosts is root-owned; once the root service is
52
- # installed the guidance is "run yamine hosts sync" — so
53
- # make that command work by re-running it elevated.
54
- puts "Writing /etc/hosts needs root — re-running elevated..."
55
- state = Certs.state_dir
56
- cmd = ["env", "YAMINE_STATE_DIR=#{state}", RbConfig.ruby,
57
- ProxyControl.bin_path, "hosts", "sync"]
58
- exit(elevate(cmd) ? 0 : 1)
59
- else
60
- $stderr.puts "Could not write /etc/hosts (try sudo)."
56
+ store = privileged_store(ctx)
57
+ hostnames = store.load_routes.map { |r| r["hostname"] }
58
+ begin
59
+ tlds = Hosts.default_scope_tlds(store.dir)
60
+ if Hosts.sync(hostnames, Hosts::PATH, tlds: tlds)
61
+ puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
62
+ elsif !ProxyControl.root? && !hostnames.empty?
63
+ # /etc/hosts is root-owned; once the root service is
64
+ # installed the guidance is "run yamine hosts sync" — so
65
+ # make that command work by re-running it elevated through
66
+ # the STAGED payload (root-owned, version-independent).
67
+ # The gem directory is never executed as root here.
68
+ puts "Writing /etc/hosts needs root — re-running elevated..."
69
+ unless PrivilegedPayload.staged?
70
+ $stderr.puts "No staged payload is installed — run once in a terminal: sudo yamine service install"
71
+ $stderr.puts " (that stages the root-owned payload; steady-state syncs then work via the grant from `yamine sudoers`)"
72
+ exit 1
73
+ end
74
+ exit(elevate(granted_hosts_sync_argv) ? 0 : 1)
75
+ else
76
+ $stderr.puts "Could not write /etc/hosts (try sudo)."
77
+ exit 1
78
+ end
79
+ rescue Error => e
80
+ $stderr.puts "Error: #{e.message}"
61
81
  exit 1
62
82
  end
63
83
  when "clean"
@@ -91,7 +111,34 @@ module Yamine
91
111
  Proxy.new(store: ctx.store, port: port, tls: tls,
92
112
  state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
93
113
  else
94
- ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
114
+ # Privileged ports need root exactly like the boot path:
115
+ # spawn elevated when interactive (one sudo prompt),
116
+ # otherwise fail with the setup hint — never a doomed
117
+ # unprivileged child that dies of EACCES.
118
+ privileged = port < 1024 && !ProxyControl.root?
119
+ if privileged && !ctx.interactive?
120
+ $stderr.puts "Error: proxy is not running and port #{port} needs root."
121
+ $stderr.puts " Human: run this once in a terminal — yamine setup (or: sudo yamine service install)"
122
+ $stderr.puts " Agent/CI: the 443 service is installed by a human once per machine — it cannot be provisioned passwordlessly."
123
+ $stderr.puts " Steady-state hosts sync works via the grant instead:"
124
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
125
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
126
+ $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
127
+ exit 1
128
+ end
129
+ begin
130
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls,
131
+ sudo: privileged, tlds: tlds)
132
+ rescue ProxyAlreadyRunningError => e
133
+ # Idempotent: the proxy we wanted is already serving, so
134
+ # report that instead of a false "started".
135
+ puts e.message
136
+ return
137
+ rescue ProxyNotRunningError => e
138
+ $stderr.puts "Error: #{e.message.lines.first&.strip}"
139
+ $stderr.puts " Fix once: yamine setup"
140
+ exit 1
141
+ end
95
142
  puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
96
143
  end
97
144
  when "stop"
@@ -102,10 +149,12 @@ module Yamine
102
149
  when :unknown_process then puts "Port in use by an unknown process."
103
150
  when :needs_root
104
151
  # A root service keeps serving; say so rather than implying
105
- # success. Uninstalling is the real answer — signalling it
106
- # would just leave launchd's KeepAlive restarting it.
107
- $stderr.puts "The proxy runs as root (the launchd/systemd service)."
108
- $stderr.puts " Uninstall it: sudo yamine service uninstall"
152
+ # success. Restarting picks up new state, uninstalling
153
+ # removes it — signalling the pid would just leave
154
+ # launchd's KeepAlive restarting it.
155
+ $stderr.puts "The proxy runs as root (the launchd/systemd service) and is still serving."
156
+ $stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
157
+ $stderr.puts " Or uninstall it: sudo yamine service uninstall"
109
158
  exit 1
110
159
  end
111
160
  else
@@ -169,29 +218,67 @@ module Yamine
169
218
  when "install" then exit(service_install(ctx, args) ? 0 : 1)
170
219
  when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
171
220
  when "status" then service_status(ctx)
172
- else raise Error, "Usage: yamine service [install|uninstall|status]"
221
+ when "stage" then exit(service_stage(ctx, args) ? 0 : 1)
222
+ else raise Error, "Usage: yamine service [install|uninstall|status|stage]"
173
223
  end
174
224
  end
175
225
 
176
- # Print the scoped passwordless-sudo rules that let `service install`
177
- # (and only it) run without a prompt. The service re-execs the whole
178
- # gem under sudo, so the safe NOPASSWD grants exactly the gem path +
179
- # subcommand for the current user — never a bare interpreter. This is
180
- # how agents and repeat machines get clean :443 without a TTY.
226
+ # The exact argv sudo runs for each passwordless grant rule.
227
+ # Single source of truth: `sudoers` prints these and the
228
+ # elevation call sites invoke them byte-identical. sudo matches
229
+ # the command path plus every concatenated argument and strips
230
+ # no `env` prefix, so any drift here — an env wrapper, a
231
+ # reordered flag, an unescaped space — silently disables the
232
+ # grant and `sudo -n` fails with "a password is required". The
233
+ # parity test pins both sides to these arrays. Evaluated per
234
+ # call (not constants): the staged path honors
235
+ # YAMINE_PRIVILEGED_ROOT.
236
+ def granted_hosts_sync_argv
237
+ [RbConfig.ruby, PrivilegedPayload.bin_path, "hosts", "sync"]
238
+ end
239
+
240
+ def granted_uninstall_argv
241
+ [RbConfig.ruby, PrivilegedPayload.bin_path, "service", "uninstall", "--internal"]
242
+ end
243
+
244
+ # sudoers splits a command spec on unescaped spaces, so a staged
245
+ # path containing one ("/Library/Application Support/...") must
246
+ # be escaped or the rule can never match anything.
247
+ def sudoers_escape(word)
248
+ word.to_s.gsub(" ", "\\ ")
249
+ end
250
+
251
+ # Print the scoped passwordless-sudo rules for steady-state agent
252
+ # work. The grant pins the ROOT-OWNED staged payload at its
253
+ # version-independent path — never the user-writable gem
254
+ # directory — and keeps only what can never introduce or modify
255
+ # root-executed code: the data-only `hosts sync` (hostnames
256
+ # strictly validated inside the managed /etc/hosts block) and
257
+ # `service uninstall` (which only deletes yamine's own files).
258
+ # Installing or upgrading the staged payload stages user-writable
259
+ # source into root-owned paths, so it stays a human-authorized
260
+ # interactive sudo and is deliberately NOT in this grant.
261
+ #
262
+ # Re-run after upgrading if your /etc/sudoers.d/yamine still pins
263
+ # a version-stamped gem path: those rules go stale every release.
264
+ # This output never does.
181
265
  #
182
266
  # macOS: sudo install -o root -g wheel -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
183
267
  # Linux: sudo install -o root -g root -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
184
268
  def sudoers(_ctx, _args)
185
269
  require "etc"
186
- ruby = RbConfig.ruby
187
- bin = ProxyControl.bin_path
188
270
  user = ENV.fetch("USER", Etc.getlogin)
271
+ rules = [granted_hosts_sync_argv, granted_uninstall_argv].map do |argv|
272
+ "#{user} ALL=(root) NOPASSWD: #{argv.map { |w| sudoers_escape(w) }.join(" ")}"
273
+ end
189
274
  puts <<~SUDOERS
190
- # yamine: let #{user} install/run the privileged proxy on port 443
191
- # without a password prompt. Scoped to yamine's own service
192
- # re-exec — the gem path above, not a bare interpreter.
193
- #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
194
- #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service uninstall --internal
275
+ # yamine: steady-state passwordless rules for #{user}. The staged
276
+ # payload below is root-owned at a version-independent path, so
277
+ # these rules survive gem upgrades — re-run `yamine sudoers` once
278
+ # if your installed file still pins a version-stamped gem path.
279
+ # Service install/upgrade is deliberately absent: staging new
280
+ # root-executed code needs an interactive sudo (Touch ID).
281
+ #{rules.join("\n")}
195
282
  SUDOERS
196
283
  end
197
284
 
@@ -201,11 +288,12 @@ module Yamine
201
288
  # routes registered by unprivileged CLIs are shared. The root half
202
289
  # can also write /etc/hosts.
203
290
  #
204
- # Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
205
- # succeeds only when the scoped NOPASSWD grant from `yamine
206
- # sudoers` is installed, and fails fast with guidance otherwise.
207
- # Interactive runs use plain sudo (one password, then the service
208
- # is installed for good).
291
+ # Staging user-writable source into root-owned paths is never
292
+ # passwordless: non-interactive runs (agents, CI) fail fast
293
+ # pointing at the human step, because the grant from `yamine
294
+ # sudoers` deliberately covers no install rule. Interactive runs
295
+ # use plain sudo (one Touch ID tap), then the service is installed
296
+ # for good.
209
297
  #
210
298
  # Returns true when the service is installed. No exit here: the
211
299
  # bare `service install` CLI exits in `service`, while `setup`
@@ -214,18 +302,32 @@ module Yamine
214
302
  if ProxyControl.root?
215
303
  install_service!(ctx)
216
304
  elsif args.include?("--internal")
217
- raise Error, "`service install --internal` is the root half of the sudo re-exec — run `yamine service install`"
305
+ raise Error, "`service install --internal` is the root half of the sudo re-exec — run `sudo yamine service install` in a terminal"
306
+ elsif !ctx.interactive?
307
+ $stderr.puts "service install stages root-executed code, so it needs an interactive sudo (one Touch ID tap) — it is deliberately not covered by the passwordless grant."
308
+ $stderr.puts " Human: run in a terminal — sudo yamine service install"
309
+ $stderr.puts " Agents: the 443 service is installed by a human once per machine; steady-state hosts sync works via the grant — yamine sudoers"
310
+ false
218
311
  else
219
312
  puts "Installing system service (sudo required)..."
220
- state = Certs.state_dir
221
- cmd = ["env", "YAMINE_STATE_DIR=#{state}",
222
- RbConfig.ruby, ProxyControl.bin_path,
313
+ # No `env` prefix: nothing crosses the sudo boundary, and no
314
+ # grant rule needs to match this — the human sudo prompts.
315
+ # The root half derives the invoking user's state dir from
316
+ # SUDO_USER (see privileged_store).
317
+ cmd = [RbConfig.ruby, ProxyControl.bin_path,
223
318
  "service", "install", "--internal"]
224
319
  elevate(cmd)
225
320
  end
226
321
  end
227
322
 
228
323
  def install_service!(ctx)
324
+ # Stage first, register second: a staging failure raises before
325
+ # any unit exists, so a failed install can never leave a unit
326
+ # pointing at an unverified payload — and the running daemon
327
+ # (legacy or current) keeps serving untouched.
328
+ store = privileged_store(ctx)
329
+ version = PrivilegedPayload.stage!(state_dir: store.dir)
330
+ puts " Staged privileged payload v#{version} (#{PrivilegedPayload.root_dir})."
229
331
  case RUBY_PLATFORM
230
332
  when /darwin/ then install_launchd(ctx)
231
333
  when /linux/ then install_systemd
@@ -235,27 +337,68 @@ module Yamine
235
337
  # far while elevated — Safari works the moment setup finishes
236
338
  # (Chrome resolves *.localhost natively).
237
339
  sync_hosts_from_routes(ctx)
340
+ print_interpreter_note
341
+ true
342
+ end
343
+
344
+ # The interpreter caveat, said out loud at every install: the unit
345
+ # runs a user-writable Ruby (no root-owned Ruby >= 3.2 exists on
346
+ # this machine), so the payload being root-owned is necessary but
347
+ # not sufficient. Never papered over.
348
+ # Root half of the sudo-daemon staging step (`service stage
349
+ # --internal`): create the root-owned payload from the running gem
350
+ # source. Invoked only under an already human-authorized sudo —
351
+ # the --no-service fallback's prompt, or an explicit admin sudo —
352
+ # which is what authorizes running the gem source once to mint the
353
+ # payload the daemon then runs. Never granted, never passwordless.
354
+ def service_stage(ctx, args)
355
+ unless ProxyControl.root? && args.include?("--internal")
356
+ raise Error, "`service stage --internal` is the root half of the sudo-daemon staging step — run `yamine setup --no-service` or `sudo yamine service install`"
357
+ end
358
+ store = privileged_store(ctx)
359
+ version = PrivilegedPayload.stage!(state_dir: store.dir)
360
+ puts "Staged privileged payload v#{version} (#{PrivilegedPayload.root_dir})."
361
+ print_interpreter_note
238
362
  true
239
363
  end
240
364
 
365
+ def print_interpreter_note
366
+ ruby = PrivilegedPayload.ruby_info
367
+ return unless ruby[:writable]
368
+
369
+ puts " NOTE: the service runs #{ruby[:path]}, which is user-writable —"
370
+ puts " only the staged payload above is root-owned. See README (port 443) for the residual risk."
371
+ end
372
+
241
373
  def sync_hosts_from_routes(ctx)
242
- hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
374
+ store = privileged_store(ctx)
375
+ hostnames = store.load_routes.map { |r| r["hostname"] }
243
376
  if hostnames.empty?
244
377
  puts " No routes registered yet — hosts sync will happen on the next boot."
245
378
  elsif Yamine::Hosts.synced?(hostnames)
246
379
  puts " /etc/hosts already lists #{hostnames.length} hostname(s)."
247
- elsif Yamine::Hosts.sync(hostnames)
248
- puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
249
380
  else
250
- warn " could not write /etc/hosts (run `sudo yamine hosts sync` later)"
381
+ begin
382
+ if Yamine::Hosts.sync(hostnames, Yamine::Hosts::PATH,
383
+ tlds: Yamine::Hosts.default_scope_tlds(store.dir))
384
+ puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
385
+ else
386
+ warn " could not write /etc/hosts (run `sudo yamine hosts sync` later)"
387
+ end
388
+ rescue Error => e
389
+ warn " hosts sync skipped: #{e.message}"
390
+ end
251
391
  end
252
392
  end
253
393
 
254
394
  # Run a privileged command via sudo. Interactive: plain sudo (one
255
- # prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
256
- # NOPASSWD grant from `yamine sudoers`. On failure prints the
395
+ # prompt). Non-interactive: `sudo -n` — no prompt ever; requires
396
+ # the NOPASSWD grant from `yamine sudoers`. On failure prints the
257
397
  # provisioning hint so agents/CI know exactly what to install.
258
- def elevate(cmd)
398
+ # The :grant hint names the passwordless rules (hosts sync,
399
+ # uninstall); :human points at the interactive install step, for
400
+ # commands no grant will ever cover.
401
+ def elevate(cmd, hint: :grant)
259
402
  interactive = $stdin.tty? && ENV["CI"].nil?
260
403
  sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
261
404
  ok = Command.run(*sudo_args, *cmd)
@@ -267,6 +410,9 @@ module Yamine
267
410
  # runs, where a missing NOPASSWD rule is the usual cause.
268
411
  if interactive
269
412
  $stderr.puts "sudo failed — re-run `yamine setup` to try again."
413
+ elsif hint == :human
414
+ $stderr.puts "sudo failed — this step needs an interactive sudo (one Touch ID tap)."
415
+ $stderr.puts " Human: run in a terminal — sudo yamine service install"
270
416
  else
271
417
  $stderr.puts "sudo failed — install the scoped grant once:"
272
418
  $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
@@ -287,12 +433,42 @@ module Yamine
287
433
  Certs.home
288
434
  end
289
435
 
290
- def install_launchd(ctx)
436
+ # The invoking user's state dir inside the root half of a sudo
437
+ # re-exec. Nothing crosses the sudo boundary (no `env` prefix, no
438
+ # SETENV — which would let RUBYOPT/RUBYLIB smuggle code into the
439
+ # root process): sudo sets SUDO_USER itself, while env_reset makes
440
+ # HOME root's, so HOME alone is not enough. Same SUDO_USER
441
+ # precedent as user_home_for_service; unprivileged resolution
442
+ # (Certs.state_dir) is untouched.
443
+ def invoking_state_dir
444
+ File.join(user_home_for_service, ".yamine")
445
+ end
446
+
447
+ # The store a root half acts on. As root without an explicit dir,
448
+ # ctx.store would resolve through root's HOME — but the invoking
449
+ # user's routes live under their own dir, so derive it from
450
+ # SUDO_USER instead. An explicit dir survives only when a human
451
+ # passed it through sudo deliberately (env_reset strips it
452
+ # otherwise); honor that so custom state dirs keep working.
453
+ # Unprivileged runs keep ctx.store untouched.
454
+ def privileged_store(ctx)
455
+ return ctx.store unless ProxyControl.root?
456
+
457
+ env_dir = ENV["YAMINE_STATE_DIR"]
458
+ return ctx.store if env_dir && !env_dir.empty?
459
+
460
+ RouteStore.new(invoking_state_dir, on_warning: ->(m) { warn m })
461
+ end
462
+
463
+ def install_launchd(ctx, dir = "/Library/LaunchDaemons")
291
464
  require "etc"
292
465
  home = user_home_for_service
293
466
  state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
294
- dir = "/Library/LaunchDaemons"
295
- FileUtils.mkdir_p(dir)
467
+ # The unit references ONLY the root-owned staged payload — never
468
+ # the user-writable gem directory it was staged from. (The
469
+ # interpreter stays RbConfig.ruby: no root-owned Ruby >= 3.2
470
+ # exists, so that residual risk is reported, not hidden.)
471
+ payload_bin = PrivilegedPayload.bin_path
296
472
  plist = <<~PLIST
297
473
  <?xml version="1.0" encoding="UTF-8"?>
298
474
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -302,7 +478,7 @@ module Yamine
302
478
  <key>ProgramArguments</key>
303
479
  <array>
304
480
  <string>#{RbConfig.ruby}</string>
305
- <string>#{ProxyControl.bin_path}</string>
481
+ <string>#{payload_bin}</string>
306
482
  <string>proxy</string><string>start</string><string>--foreground</string>
307
483
  <string>--port</string><string>#{ProxyControl::DEFAULT_TLS_PORT}</string>
308
484
  </array>
@@ -317,6 +493,7 @@ module Yamine
317
493
  </plist>
318
494
  PLIST
319
495
  remove_legacy_launchd(dir)
496
+ FileUtils.mkdir_p(dir)
320
497
  path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
321
498
  File.write(path, plist)
322
499
  File.chmod(0o644, path)
@@ -332,6 +509,7 @@ module Yamine
332
509
  puts " Registering the launchd service on port 443..."
333
510
  launchctl_bootstrap(path)
334
511
  puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
512
+ puts " Payload: #{payload_bin} (root-owned, version-independent)."
335
513
  end
336
514
 
337
515
  # True when the trust store covers the CA this machine should be
@@ -407,7 +585,8 @@ module Yamine
407
585
  end
408
586
 
409
587
  # Pure unit-file builder (testable without root). Binds 80/443 at
410
- # boot; the proxy runs with the invoking user's state dir.
588
+ # boot; the proxy runs with the invoking user's state dir. The
589
+ # payload is the root-owned staged path — never the gem directory.
411
590
  def systemd_unit
412
591
  home = user_home_for_service
413
592
  state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
@@ -417,7 +596,7 @@ module Yamine
417
596
  After=network.target
418
597
 
419
598
  [Service]
420
- ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
599
+ ExecStart=#{RbConfig.ruby} #{PrivilegedPayload.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
421
600
  Environment=YAMINE_STATE_DIR=#{state_dir}
422
601
  Environment=HOME=#{home}
423
602
 
@@ -429,8 +608,7 @@ module Yamine
429
608
  # Install + start the systemd unit (mirrors portless). We are root
430
609
  # here (sudo re-exec). The unit is written root-owned, then enabled
431
610
  # and started.
432
- def install_systemd
433
- unit_path = "/etc/systemd/system/yamine.service"
611
+ def install_systemd(unit_path = "/etc/systemd/system/yamine.service")
434
612
  File.write(unit_path, systemd_unit)
435
613
  File.chmod(0o644, unit_path)
436
614
  File.chown(0, 0, unit_path) if Process.uid.zero?
@@ -438,36 +616,59 @@ module Yamine
438
616
  Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
439
617
  Command.run("systemctl", "enable", "--now", "yamine") or raise Error, "systemctl enable failed"
440
618
  puts "Installed systemd service yamine on port 443."
619
+ puts " Payload: #{PrivilegedPayload.bin_path} (root-owned, version-independent)."
441
620
  end
442
621
 
443
622
  def service_uninstall(ctx)
444
623
  if !ProxyControl.root?
445
- puts "Removing system service (sudo required)..."
446
- state = Certs.state_dir
447
- cmd = ["env", "YAMINE_STATE_DIR=#{state}",
448
- RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
449
- return elevate(cmd)
624
+ if PrivilegedPayload.staged?
625
+ # The grant covers exactly this argv: the root-owned staged
626
+ # payload removing yamine's own files. Passwordless-capable.
627
+ puts "Removing system service (sudo required)..."
628
+ return elevate(granted_uninstall_argv)
629
+ elsif !ctx.interactive?
630
+ $stderr.puts "No staged payload is installed, so uninstall needs an interactive sudo."
631
+ $stderr.puts " Human: run in a terminal — sudo yamine service uninstall"
632
+ return false
633
+ else
634
+ # Legacy cleanup: no staged payload, but a legacy unit may
635
+ # still be installed. Human sudo only — no grant covers the
636
+ # gem path anymore, and nothing crosses the sudo boundary.
637
+ puts "Removing system service (sudo required)..."
638
+ cmd = [RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
639
+ return elevate(cmd, hint: :human)
640
+ end
450
641
  end
451
642
  case RUBY_PLATFORM
452
643
  when /darwin/
453
- path = "/Library/LaunchDaemons/#{LAUNCHD_LABEL}.plist"
454
- launchctl_bootout(path) if File.file?(path)
455
- FileUtils.rm_f(path)
456
- # Also clear any pre-rename service, so uninstall leaves no root
457
- # proxy behind on a machine upgraded from an older yamine.
458
- remove_legacy_launchd
459
- puts "Removed root LaunchDaemon."
644
+ uninstall_launchd
460
645
  when /linux/
461
- Command.run("systemctl", "disable", "--now", "yamine")
462
- Command.run("systemctl", "daemon-reload")
463
- FileUtils.rm_f("/etc/systemd/system/yamine.service")
464
- puts "Removed systemd service yamine."
646
+ uninstall_systemd
465
647
  else
466
648
  raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
467
649
  end
650
+ PrivilegedPayload.remove_payload!
651
+ puts "Removed staged payload (#{PrivilegedPayload.root_dir})."
468
652
  true
469
653
  end
470
654
 
655
+ def uninstall_launchd(dir = "/Library/LaunchDaemons")
656
+ path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
657
+ launchctl_bootout(path) if File.file?(path)
658
+ FileUtils.rm_f(path)
659
+ # Also clear any pre-rename service, so uninstall leaves no root
660
+ # proxy behind on a machine upgraded from an older yamine.
661
+ remove_legacy_launchd(dir)
662
+ puts "Removed root LaunchDaemon."
663
+ end
664
+
665
+ def uninstall_systemd(unit_path = "/etc/systemd/system/yamine.service")
666
+ Command.run("systemctl", "disable", "--now", "yamine")
667
+ Command.run("systemctl", "daemon-reload")
668
+ FileUtils.rm_f(unit_path)
669
+ puts "Removed systemd service yamine."
670
+ end
671
+
471
672
  def service_status(ctx)
472
673
  port = ProxyControl.proxy_port(ctx.store)
473
674
  if port.nil? || !ProxyControl.listening?(port)
@@ -557,6 +758,8 @@ module Yamine
557
758
  (no separate GUI authorization popup).
558
759
  --no-service: trust the CA at user level, then run a sudo
559
760
  daemon instead (no boot persistence; ephemeral machines).
761
+ The daemon runs the staged root-owned payload, staging it
762
+ first under the same sudo when missing.
560
763
 
561
764
  Both finish by syncing /etc/hosts and verifying with doctor.
562
765
  HELP
@@ -603,8 +806,13 @@ module Yamine
603
806
  # The root service install already synced under elevation; a
604
807
  # plain re-run must not fail rewriting /etc/hosts unprivileged
605
808
  # when the block is already in place.
606
- unless Yamine::Hosts.synced?(hostnames) || Yamine::Hosts.sync(hostnames)
607
- abort_setup("Could not write /etc/hosts.",
809
+ begin
810
+ unless Yamine::Hosts.synced?(hostnames) || Yamine::Hosts.sync(hostnames)
811
+ abort_setup("Could not write /etc/hosts.",
812
+ "Run `sudo yamine hosts sync`, then re-run `yamine setup`.")
813
+ end
814
+ rescue Error => e
815
+ abort_setup("Could not sync /etc/hosts: #{e.message}",
608
816
  "Run `sudo yamine hosts sync`, then re-run `yamine setup`.")
609
817
  end
610
818
  end
@@ -634,12 +842,15 @@ module Yamine
634
842
  end
635
843
 
636
844
  # The two ways to get a privileged proxy on 443: a human runs setup
637
- # once (interactive sudo), or an agent/CI image is pre-provisioned
638
- # with the scoped NOPASSWD rules from `yamine sudoers`.
845
+ # once (interactive sudo), or the machine already has the root
846
+ # service and agents keep its hosts entries fresh through the
847
+ # passwordless hosts-sync grant from `yamine sudoers`. Service
848
+ # install itself is never passwordless.
639
849
  def privileged_port_hint
640
850
  [
641
- "Human: run this once — yamine setup",
642
- "Agent/CI: pre-provision passwordless sudo once —",
851
+ "Human: run this once in a terminal — yamine setup (or: sudo yamine service install)",
852
+ "Agent/CI: the 443 service is installed by a human once per machine;",
853
+ " steady-state hosts sync works via the grant instead —",
643
854
  " yamine sudoers > /tmp/yamine.sudoers",
644
855
  " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
645
856
  ]
@@ -662,13 +873,18 @@ module Yamine
662
873
  port, tls = 443, true
663
874
  unless ctx.interactive?
664
875
  warn " no TTY available for the sudo prompt."
665
- warn " Agent/CI: pre-provision passwordless sudo once —"
876
+ warn " A human installs the 443 service once per machine (yamine setup in a terminal);"
877
+ warn " steady-state hosts sync works via the grant instead:"
666
878
  warn " yamine sudoers > /tmp/yamine.sudoers"
667
879
  warn " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
668
880
  return false
669
881
  end
670
882
  ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
671
883
  wait_for_ours(ctx, port, tls: tls)
884
+ rescue ProxyAlreadyRunningError
885
+ # The root service (or another daemon) already serves 443: that
886
+ # IS the privileged proxy setup wanted — verify and report.
887
+ wait_for_ours(ctx, port, tls: tls)
672
888
  rescue Yamine::ProxyNotRunningError, SystemCallError => e
673
889
  warn " daemon start failed: #{e.message.lines.first&.strip}"
674
890
  false
@@ -828,8 +1044,12 @@ module Yamine
828
1044
  # is the common case and must stay silent: warning there sent
829
1045
  # people to a sudo write for a file that needed nothing.
830
1046
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
831
- unless hostnames.empty? || Yamine::Hosts.synced?(hostnames) || Hosts.sync(hostnames)
832
- warn "Warning: could not write /etc/hosts (try sudo yamine hosts sync)."
1047
+ begin
1048
+ unless hostnames.empty? || Yamine::Hosts.synced?(hostnames) || Hosts.sync(hostnames)
1049
+ warn "Warning: could not write /etc/hosts (try sudo yamine hosts sync)."
1050
+ end
1051
+ rescue Error => e
1052
+ warn "Warning: #{e.message}"
833
1053
  end
834
1054
  end
835
1055
  # yamine kamal <variant> [--app myapp] [--domain preview.example.com]
@@ -668,7 +668,12 @@ module Yamine
668
668
  def resync_hosts(ctx)
669
669
  ctx.store.prune_stale
670
670
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
671
- return if Hosts.sync(hostnames)
671
+ begin
672
+ return if Hosts.sync(hostnames)
673
+ rescue Error => e
674
+ warn "Warning: #{e.message}"
675
+ return
676
+ end
672
677
 
673
678
  warn "Warning: could not update /etc/hosts (try sudo yamine hosts sync)."
674
679
  end
data/lib/yamine/cli.rb CHANGED
@@ -99,7 +99,7 @@ module Yamine
99
99
  yamine skills install Install yamine skill into ~/.agents/skills/ (or --local)
100
100
  yamine proxy start|stop Control the proxy
101
101
  yamine service install|status|uninstall OS startup service
102
- yamine sudoers Print scoped passwordless-sudo rules for port 443
102
+ yamine sudoers Print scoped passwordless-sudo rules for steady-state hosts sync
103
103
  yamine hosts sync|clean Manage /etc/hosts entries
104
104
  yamine kamal <variant> Preview-deploy snippet for Kamal
105
105
  yamine stop Stop this app's backend + routes