yamine 0.20.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
@@ -53,20 +53,31 @@ module Yamine
53
53
  sub = args.first
54
54
  case sub
55
55
  when "sync"
56
- hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
57
- if Hosts.sync(hostnames)
58
- puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
59
- elsif !ProxyControl.root? && !hostnames.empty?
60
- # /etc/hosts is root-owned; once the root service is
61
- # installed the guidance is "run yamine hosts sync" — so
62
- # make that command work by re-running it elevated.
63
- puts "Writing /etc/hosts needs root — re-running elevated..."
64
- state = Certs.state_dir
65
- cmd = ["env", "YAMINE_STATE_DIR=#{state}", RbConfig.ruby,
66
- ProxyControl.bin_path, "hosts", "sync"]
67
- exit(elevate(cmd) ? 0 : 1)
68
- else
69
- $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}"
70
81
  exit 1
71
82
  end
72
83
  when "clean"
@@ -107,10 +118,11 @@ module Yamine
107
118
  privileged = port < 1024 && !ProxyControl.root?
108
119
  if privileged && !ctx.interactive?
109
120
  $stderr.puts "Error: proxy is not running and port #{port} needs root."
110
- $stderr.puts " Human: run this once — yamine setup"
111
- $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
112
- $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
113
- $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
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"
114
126
  $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
115
127
  exit 1
116
128
  end
@@ -206,29 +218,67 @@ module Yamine
206
218
  when "install" then exit(service_install(ctx, args) ? 0 : 1)
207
219
  when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
208
220
  when "status" then service_status(ctx)
209
- 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]"
210
223
  end
211
224
  end
212
225
 
213
- # Print the scoped passwordless-sudo rules that let `service install`
214
- # (and only it) run without a prompt. The service re-execs the whole
215
- # gem under sudo, so the safe NOPASSWD grants exactly the gem path +
216
- # subcommand for the current user — never a bare interpreter. This is
217
- # 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.
218
265
  #
219
266
  # macOS: sudo install -o root -g wheel -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
220
267
  # Linux: sudo install -o root -g root -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
221
268
  def sudoers(_ctx, _args)
222
269
  require "etc"
223
- ruby = RbConfig.ruby
224
- bin = ProxyControl.bin_path
225
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
226
274
  puts <<~SUDOERS
227
- # yamine: let #{user} install/run the privileged proxy on port 443
228
- # without a password prompt. Scoped to yamine's own service
229
- # re-exec — the gem path above, not a bare interpreter.
230
- #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
231
- #{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")}
232
282
  SUDOERS
233
283
  end
234
284
 
@@ -238,11 +288,12 @@ module Yamine
238
288
  # routes registered by unprivileged CLIs are shared. The root half
239
289
  # can also write /etc/hosts.
240
290
  #
241
- # Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
242
- # succeeds only when the scoped NOPASSWD grant from `yamine
243
- # sudoers` is installed, and fails fast with guidance otherwise.
244
- # Interactive runs use plain sudo (one password, then the service
245
- # 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.
246
297
  #
247
298
  # Returns true when the service is installed. No exit here: the
248
299
  # bare `service install` CLI exits in `service`, while `setup`
@@ -251,18 +302,32 @@ module Yamine
251
302
  if ProxyControl.root?
252
303
  install_service!(ctx)
253
304
  elsif args.include?("--internal")
254
- 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
255
311
  else
256
312
  puts "Installing system service (sudo required)..."
257
- state = Certs.state_dir
258
- cmd = ["env", "YAMINE_STATE_DIR=#{state}",
259
- 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,
260
318
  "service", "install", "--internal"]
261
319
  elevate(cmd)
262
320
  end
263
321
  end
264
322
 
265
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})."
266
331
  case RUBY_PLATFORM
267
332
  when /darwin/ then install_launchd(ctx)
268
333
  when /linux/ then install_systemd
@@ -272,27 +337,68 @@ module Yamine
272
337
  # far while elevated — Safari works the moment setup finishes
273
338
  # (Chrome resolves *.localhost natively).
274
339
  sync_hosts_from_routes(ctx)
340
+ print_interpreter_note
275
341
  true
276
342
  end
277
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
362
+ true
363
+ end
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
+
278
373
  def sync_hosts_from_routes(ctx)
279
- hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
374
+ store = privileged_store(ctx)
375
+ hostnames = store.load_routes.map { |r| r["hostname"] }
280
376
  if hostnames.empty?
281
377
  puts " No routes registered yet — hosts sync will happen on the next boot."
282
378
  elsif Yamine::Hosts.synced?(hostnames)
283
379
  puts " /etc/hosts already lists #{hostnames.length} hostname(s)."
284
- elsif Yamine::Hosts.sync(hostnames)
285
- puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
286
380
  else
287
- 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
288
391
  end
289
392
  end
290
393
 
291
394
  # Run a privileged command via sudo. Interactive: plain sudo (one
292
- # prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
293
- # 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
294
397
  # provisioning hint so agents/CI know exactly what to install.
295
- 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)
296
402
  interactive = $stdin.tty? && ENV["CI"].nil?
297
403
  sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
298
404
  ok = Command.run(*sudo_args, *cmd)
@@ -304,6 +410,9 @@ module Yamine
304
410
  # runs, where a missing NOPASSWD rule is the usual cause.
305
411
  if interactive
306
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"
307
416
  else
308
417
  $stderr.puts "sudo failed — install the scoped grant once:"
309
418
  $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
@@ -324,12 +433,42 @@ module Yamine
324
433
  Certs.home
325
434
  end
326
435
 
327
- 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")
328
464
  require "etc"
329
465
  home = user_home_for_service
330
466
  state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
331
- dir = "/Library/LaunchDaemons"
332
- 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
333
472
  plist = <<~PLIST
334
473
  <?xml version="1.0" encoding="UTF-8"?>
335
474
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -339,7 +478,7 @@ module Yamine
339
478
  <key>ProgramArguments</key>
340
479
  <array>
341
480
  <string>#{RbConfig.ruby}</string>
342
- <string>#{ProxyControl.bin_path}</string>
481
+ <string>#{payload_bin}</string>
343
482
  <string>proxy</string><string>start</string><string>--foreground</string>
344
483
  <string>--port</string><string>#{ProxyControl::DEFAULT_TLS_PORT}</string>
345
484
  </array>
@@ -354,6 +493,7 @@ module Yamine
354
493
  </plist>
355
494
  PLIST
356
495
  remove_legacy_launchd(dir)
496
+ FileUtils.mkdir_p(dir)
357
497
  path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
358
498
  File.write(path, plist)
359
499
  File.chmod(0o644, path)
@@ -369,6 +509,7 @@ module Yamine
369
509
  puts " Registering the launchd service on port 443..."
370
510
  launchctl_bootstrap(path)
371
511
  puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
512
+ puts " Payload: #{payload_bin} (root-owned, version-independent)."
372
513
  end
373
514
 
374
515
  # True when the trust store covers the CA this machine should be
@@ -444,7 +585,8 @@ module Yamine
444
585
  end
445
586
 
446
587
  # Pure unit-file builder (testable without root). Binds 80/443 at
447
- # 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.
448
590
  def systemd_unit
449
591
  home = user_home_for_service
450
592
  state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
@@ -454,7 +596,7 @@ module Yamine
454
596
  After=network.target
455
597
 
456
598
  [Service]
457
- 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}
458
600
  Environment=YAMINE_STATE_DIR=#{state_dir}
459
601
  Environment=HOME=#{home}
460
602
 
@@ -466,8 +608,7 @@ module Yamine
466
608
  # Install + start the systemd unit (mirrors portless). We are root
467
609
  # here (sudo re-exec). The unit is written root-owned, then enabled
468
610
  # and started.
469
- def install_systemd
470
- unit_path = "/etc/systemd/system/yamine.service"
611
+ def install_systemd(unit_path = "/etc/systemd/system/yamine.service")
471
612
  File.write(unit_path, systemd_unit)
472
613
  File.chmod(0o644, unit_path)
473
614
  File.chown(0, 0, unit_path) if Process.uid.zero?
@@ -475,36 +616,59 @@ module Yamine
475
616
  Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
476
617
  Command.run("systemctl", "enable", "--now", "yamine") or raise Error, "systemctl enable failed"
477
618
  puts "Installed systemd service yamine on port 443."
619
+ puts " Payload: #{PrivilegedPayload.bin_path} (root-owned, version-independent)."
478
620
  end
479
621
 
480
622
  def service_uninstall(ctx)
481
623
  if !ProxyControl.root?
482
- puts "Removing system service (sudo required)..."
483
- state = Certs.state_dir
484
- cmd = ["env", "YAMINE_STATE_DIR=#{state}",
485
- RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
486
- 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
487
641
  end
488
642
  case RUBY_PLATFORM
489
643
  when /darwin/
490
- path = "/Library/LaunchDaemons/#{LAUNCHD_LABEL}.plist"
491
- launchctl_bootout(path) if File.file?(path)
492
- FileUtils.rm_f(path)
493
- # Also clear any pre-rename service, so uninstall leaves no root
494
- # proxy behind on a machine upgraded from an older yamine.
495
- remove_legacy_launchd
496
- puts "Removed root LaunchDaemon."
644
+ uninstall_launchd
497
645
  when /linux/
498
- Command.run("systemctl", "disable", "--now", "yamine")
499
- Command.run("systemctl", "daemon-reload")
500
- FileUtils.rm_f("/etc/systemd/system/yamine.service")
501
- puts "Removed systemd service yamine."
646
+ uninstall_systemd
502
647
  else
503
648
  raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
504
649
  end
650
+ PrivilegedPayload.remove_payload!
651
+ puts "Removed staged payload (#{PrivilegedPayload.root_dir})."
505
652
  true
506
653
  end
507
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
+
508
672
  def service_status(ctx)
509
673
  port = ProxyControl.proxy_port(ctx.store)
510
674
  if port.nil? || !ProxyControl.listening?(port)
@@ -594,6 +758,8 @@ module Yamine
594
758
  (no separate GUI authorization popup).
595
759
  --no-service: trust the CA at user level, then run a sudo
596
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.
597
763
 
598
764
  Both finish by syncing /etc/hosts and verifying with doctor.
599
765
  HELP
@@ -640,8 +806,13 @@ module Yamine
640
806
  # The root service install already synced under elevation; a
641
807
  # plain re-run must not fail rewriting /etc/hosts unprivileged
642
808
  # when the block is already in place.
643
- unless Yamine::Hosts.synced?(hostnames) || Yamine::Hosts.sync(hostnames)
644
- 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}",
645
816
  "Run `sudo yamine hosts sync`, then re-run `yamine setup`.")
646
817
  end
647
818
  end
@@ -671,12 +842,15 @@ module Yamine
671
842
  end
672
843
 
673
844
  # The two ways to get a privileged proxy on 443: a human runs setup
674
- # once (interactive sudo), or an agent/CI image is pre-provisioned
675
- # 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.
676
849
  def privileged_port_hint
677
850
  [
678
- "Human: run this once — yamine setup",
679
- "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 —",
680
854
  " yamine sudoers > /tmp/yamine.sudoers",
681
855
  " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
682
856
  ]
@@ -699,7 +873,8 @@ module Yamine
699
873
  port, tls = 443, true
700
874
  unless ctx.interactive?
701
875
  warn " no TTY available for the sudo prompt."
702
- 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:"
703
878
  warn " yamine sudoers > /tmp/yamine.sudoers"
704
879
  warn " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
705
880
  return false
@@ -869,8 +1044,12 @@ module Yamine
869
1044
  # is the common case and must stay silent: warning there sent
870
1045
  # people to a sudo write for a file that needed nothing.
871
1046
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
872
- unless hostnames.empty? || Yamine::Hosts.synced?(hostnames) || Hosts.sync(hostnames)
873
- 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}"
874
1053
  end
875
1054
  end
876
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
data/lib/yamine/doctor.rb CHANGED
@@ -35,6 +35,7 @@ module Yamine
35
35
  checks << check_ca
36
36
  checks << check_ca_bundle
37
37
  checks << check_serving_ca(port, tls: tls)
38
+ checks << check_service
38
39
  checks
39
40
  end
40
41
 
@@ -277,6 +278,71 @@ module Yamine
277
278
  0
278
279
  end
279
280
 
281
+ # The privileged service: what runs as root on port 443.
282
+ #
283
+ # Three states. A legacy unit — payload inside a user home or a
284
+ # version-stamped gem directory — keeps working, so it warns (never
285
+ # fails) with the exact migration. A staged unit whose tree fails
286
+ # ownership verification FAILS: user-writable code running as root
287
+ # is the hole this check exists to close. Version skew between the
288
+ # staged payload and this CLI warns. The interpreter is always
289
+ # reported truthfully when a service exists: there is no root-owned
290
+ # Ruby new enough for the gem, so the daemon runs a user-writable
291
+ # interpreter and that residual risk stays visible.
292
+ def check_service
293
+ unless PrivilegedPayload.service_unit_present?
294
+ return Check.new(name: "service", ok: true,
295
+ message: "no privileged service installed — skipped")
296
+ end
297
+
298
+ ref = PrivilegedPayload.installed_payload_ref
299
+ if ref.nil?
300
+ return Check.new(name: "service", ok: true, warn: true,
301
+ message: "a privileged unit is installed but its payload path is unreadable" \
302
+ " — re-install it: sudo yamine service install")
303
+ end
304
+
305
+ if PrivilegedPayload.legacy_ref?(ref)
306
+ return Check.new(name: "service", ok: true, warn: true,
307
+ message: "the privileged service runs a user-writable legacy payload (#{ref}) — " \
308
+ "migrate it: sudo yamine service install")
309
+ end
310
+
311
+ problems = []
312
+ warnings = []
313
+ begin
314
+ PrivilegedPayload.verify!
315
+ rescue Error => e
316
+ problems << "#{e.message} — re-install it: sudo yamine service install"
317
+ end
318
+
319
+ staged = PrivilegedPayload.staged_version
320
+ if staged.nil?
321
+ warnings << "the service points at the staged payload but no staged version is recorded" \
322
+ " — re-install it: sudo yamine service install"
323
+ elsif staged != Yamine::VERSION
324
+ warnings << "the staged payload is v#{staged}, this CLI is v#{Yamine::VERSION}" \
325
+ " — re-install it: sudo yamine service install"
326
+ end
327
+
328
+ ruby = PrivilegedPayload.ruby_info
329
+ if ruby[:writable]
330
+ warnings << "the service interpreter #{ruby[:path]} is user-writable — " \
331
+ "anything that can write it changes what runs as root; only the payload above is root-owned " \
332
+ "(no root-owned Ruby >= 3.2 exists on this machine, so this residual risk stays until one does)"
333
+ end
334
+
335
+ if problems.empty? && warnings.empty?
336
+ Check.new(name: "service", ok: true,
337
+ message: "staged payload v#{staged} (#{PrivilegedPayload.bin_path})")
338
+ elsif problems.empty?
339
+ Check.new(name: "service", ok: true, warn: true, message: warnings.join("; "))
340
+ else
341
+ Check.new(name: "service", ok: false,
342
+ message: (problems + warnings).join("; "))
343
+ end
344
+ end
345
+
280
346
  def print(checks, out: $stdout, json: false)
281
347
  if json
282
348
  require "json"