yamine 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -0
- data/README.md +58 -14
- data/lib/yamine/certs.rb +107 -7
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/system.rb +303 -83
- data/lib/yamine/cli/worktree.rb +6 -1
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/doctor.rb +99 -0
- data/lib/yamine/errors.rb +4 -0
- data/lib/yamine/hosts.rb +76 -8
- data/lib/yamine/privileged_payload.rb +419 -0
- data/lib/yamine/proxy.rb +166 -33
- data/lib/yamine/proxy_control.rb +76 -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
|
|
@@ -30,7 +30,16 @@ module Yamine
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def clean(ctx, _args)
|
|
33
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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.
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
$stderr.puts "
|
|
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
|
-
|
|
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
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
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:
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
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
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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>#{
|
|
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} #{
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
|
638
|
-
#
|
|
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:
|
|
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 "
|
|
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
|
-
|
|
832
|
-
|
|
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]
|
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
|