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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +58 -14
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/system.rb +260 -81
- data/lib/yamine/cli/worktree.rb +6 -1
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/doctor.rb +66 -0
- data/lib/yamine/hosts.rb +76 -8
- data/lib/yamine/privileged_payload.rb +419 -0
- data/lib/yamine/proxy_control.rb +45 -6
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
112
|
-
$stderr.puts "
|
|
113
|
-
$stderr.puts "
|
|
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
|
-
|
|
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
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
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:
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
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
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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>#{
|
|
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} #{
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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
|
|
675
|
-
#
|
|
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:
|
|
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 "
|
|
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
|
-
|
|
873
|
-
|
|
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]
|
data/lib/yamine/cli/worktree.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"
|