yamine 0.3.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 +7 -0
- data/CHANGELOG.md +243 -0
- data/LICENSE +21 -0
- data/README.md +294 -0
- data/bin/yamine +6 -0
- data/lib/ask/skills/yamine/SKILL.md +113 -0
- data/lib/yamine/certs.rb +177 -0
- data/lib/yamine/cli/boot.rb +247 -0
- data/lib/yamine/cli/context.rb +104 -0
- data/lib/yamine/cli/routes.rb +255 -0
- data/lib/yamine/cli/system.rb +768 -0
- data/lib/yamine/cli.rb +108 -0
- data/lib/yamine/command.rb +28 -0
- data/lib/yamine/config.rb +394 -0
- data/lib/yamine/doctor.rb +133 -0
- data/lib/yamine/errors.rb +18 -0
- data/lib/yamine/framework.rb +75 -0
- data/lib/yamine/hostname.rb +44 -0
- data/lib/yamine/hosts.rb +86 -0
- data/lib/yamine/inference.rb +142 -0
- data/lib/yamine/log.rb +59 -0
- data/lib/yamine/ownership.rb +53 -0
- data/lib/yamine/ports.rb +45 -0
- data/lib/yamine/procfile.rb +137 -0
- data/lib/yamine/proxy.rb +489 -0
- data/lib/yamine/proxy_control.rb +222 -0
- data/lib/yamine/resolver.rb +92 -0
- data/lib/yamine/route_store.rb +148 -0
- data/lib/yamine/runner.rb +243 -0
- data/lib/yamine/sanitize.rb +41 -0
- data/lib/yamine/supervisor.rb +242 -0
- data/lib/yamine/trust.rb +131 -0
- data/lib/yamine/variant.rb +134 -0
- data/lib/yamine/version.rb +5 -0
- data/lib/yamine.rb +37 -0
- metadata +137 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
class CLI
|
|
5
|
+
# System commands: proxy, service, hosts, trust, clean, doctor, kamal.
|
|
6
|
+
module SystemCommand
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def doctor(ctx, args)
|
|
10
|
+
json = args.delete("--json")
|
|
11
|
+
failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout, json: !!json)
|
|
12
|
+
exit(failed.zero? ? 0 : 1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def trust(_ctx, _args)
|
|
16
|
+
result = Trust.trust
|
|
17
|
+
if result[:trusted]
|
|
18
|
+
puts "CA trusted."
|
|
19
|
+
else
|
|
20
|
+
$stderr.puts "Error: #{result[:error]}"
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clean(ctx, _args)
|
|
26
|
+
ProxyControl.stop(ctx.store)
|
|
27
|
+
result = Trust.untrust
|
|
28
|
+
puts "CA removed from trust store." if result[:removed]
|
|
29
|
+
warn "CA untrust failed: #{result[:error]}" if result[:error]
|
|
30
|
+
Hosts.clean
|
|
31
|
+
require "fileutils"
|
|
32
|
+
FileUtils.rm_rf(ctx.store.dir)
|
|
33
|
+
puts "Cleaned yamine state."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def hosts(ctx, args)
|
|
37
|
+
sub = args.first
|
|
38
|
+
case sub
|
|
39
|
+
when "sync"
|
|
40
|
+
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
41
|
+
if Hosts.sync(hostnames)
|
|
42
|
+
puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
|
|
43
|
+
elsif !ProxyControl.root? && !hostnames.empty?
|
|
44
|
+
# /etc/hosts is root-owned; once the root service is
|
|
45
|
+
# installed the guidance is "run yamine hosts sync" — so
|
|
46
|
+
# make that command work by re-running it elevated.
|
|
47
|
+
puts "Writing /etc/hosts needs root — re-running elevated..."
|
|
48
|
+
state = Certs.state_dir
|
|
49
|
+
cmd = ["env", "YAMINE_STATE_DIR=#{state}", RbConfig.ruby,
|
|
50
|
+
ProxyControl.bin_path, "hosts", "sync"]
|
|
51
|
+
exit(elevate(cmd) ? 0 : 1)
|
|
52
|
+
else
|
|
53
|
+
$stderr.puts "Could not write /etc/hosts (try sudo)."
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
when "clean"
|
|
57
|
+
Hosts.clean
|
|
58
|
+
puts "Removed yamine entries from /etc/hosts."
|
|
59
|
+
else
|
|
60
|
+
raise Error, "Usage: yamine hosts [sync|clean]"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def proxy(ctx, args)
|
|
65
|
+
sub = args.first
|
|
66
|
+
case sub
|
|
67
|
+
when "start"
|
|
68
|
+
port, tls, foreground = parse_proxy_start(args[1..])
|
|
69
|
+
tlds = active_tlds(args[1..])
|
|
70
|
+
if foreground
|
|
71
|
+
write_tls_marker(ctx, tls)
|
|
72
|
+
write_tlds_file(ctx, tlds)
|
|
73
|
+
sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
|
|
74
|
+
on_event: ->(m) { warn m })
|
|
75
|
+
sup.start
|
|
76
|
+
Proxy.new(store: ctx.store, port: port, tls: tls,
|
|
77
|
+
state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
|
|
78
|
+
else
|
|
79
|
+
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
|
|
80
|
+
puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
|
|
81
|
+
end
|
|
82
|
+
when "stop"
|
|
83
|
+
case ProxyControl.stop(ctx.store)
|
|
84
|
+
when :stopped then puts "Proxy stopped."
|
|
85
|
+
when :stale then puts "Removed stale proxy state."
|
|
86
|
+
when :not_running then puts "Proxy is not running."
|
|
87
|
+
when :unknown_process then puts "Port in use by an unknown process."
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
raise Error, "Usage: yamine proxy [start|stop]"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write_tls_marker(ctx, tls)
|
|
95
|
+
ctx.store.ensure_dir
|
|
96
|
+
path = File.join(ctx.store.dir, "proxy.tls")
|
|
97
|
+
tls ? File.write(path, "1") : File.write(path, "0")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# TLDs the proxy serves: --tld flags, else YAMINE_TLD, else
|
|
101
|
+
# localhost. Persisted so auto-restarted daemons agree, and so
|
|
102
|
+
# the 404 page knows which hosts are "ours" (rebinding boundary).
|
|
103
|
+
def active_tlds(args)
|
|
104
|
+
flags = []
|
|
105
|
+
i = 0
|
|
106
|
+
while i < args.length
|
|
107
|
+
if args[i] == "--tld"
|
|
108
|
+
flags << args.fetch(i + 1).to_s
|
|
109
|
+
i += 2
|
|
110
|
+
else
|
|
111
|
+
i += 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
list = flags.any? ? flags : (ENV["YAMINE_TLD"]&.split(",")&.map(&:strip) || [])
|
|
115
|
+
list = list.reject(&:empty?).map(&:downcase).uniq
|
|
116
|
+
list.empty? ? [Hostname::DEFAULT_TLD] : list
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def write_tlds_file(ctx, tlds)
|
|
120
|
+
ctx.store.ensure_dir
|
|
121
|
+
File.write(File.join(ctx.store.dir, "proxy.tlds"), "#{tlds.join("\n")}\n")
|
|
122
|
+
rescue SystemCallError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_proxy_start(args)
|
|
127
|
+
port = nil
|
|
128
|
+
tls = true
|
|
129
|
+
foreground = false
|
|
130
|
+
i = 0
|
|
131
|
+
while i < args.length
|
|
132
|
+
case args[i]
|
|
133
|
+
when "-p", "--port" then port = args.fetch(i + 1).to_i; i += 2
|
|
134
|
+
when "--no-tls" then tls = false; i += 1
|
|
135
|
+
when "--https" then tls = true; i += 1
|
|
136
|
+
when "--foreground" then foreground = true; i += 1
|
|
137
|
+
when "--tld" then i += 2 # consumed by active_tlds
|
|
138
|
+
else i += 1
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
[port || ProxyControl.default_port(tls), tls, foreground]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def service(ctx, args)
|
|
145
|
+
sub = args.first
|
|
146
|
+
case sub
|
|
147
|
+
when "install" then exit(service_install(ctx, args) ? 0 : 1)
|
|
148
|
+
when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
|
|
149
|
+
when "status" then service_status(ctx)
|
|
150
|
+
else raise Error, "Usage: yamine service [install|uninstall|status]"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Print the scoped passwordless-sudo rules that let `service install`
|
|
155
|
+
# (and only it) run without a prompt. The service re-execs the whole
|
|
156
|
+
# gem under sudo, so the safe NOPASSWD grants exactly the gem path +
|
|
157
|
+
# subcommand for the current user — never a bare interpreter. This is
|
|
158
|
+
# how agents and repeat machines get clean :443 without a TTY.
|
|
159
|
+
#
|
|
160
|
+
# macOS: sudo install -o root -g wheel -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
|
|
161
|
+
# Linux: sudo install -o root -g root -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
|
|
162
|
+
def sudoers(_ctx, _args)
|
|
163
|
+
require "etc"
|
|
164
|
+
ruby = RbConfig.ruby
|
|
165
|
+
bin = ProxyControl.bin_path
|
|
166
|
+
user = ENV.fetch("USER", Etc.getlogin)
|
|
167
|
+
puts <<~SUDOERS
|
|
168
|
+
# yamine: let #{user} install/run the privileged proxy on port 443
|
|
169
|
+
# without a password prompt. Scoped to yamine's own service
|
|
170
|
+
# re-exec — the gem path above, not a bare interpreter.
|
|
171
|
+
#{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
|
|
172
|
+
#{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service uninstall --internal
|
|
173
|
+
SUDOERS
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
|
|
177
|
+
# Non-root runs re-exec under sudo once (--internal marks the root
|
|
178
|
+
# half); the proxy runs with the invoking user's state dir so
|
|
179
|
+
# routes registered by unprivileged CLIs are shared. The root half
|
|
180
|
+
# can also write /etc/hosts.
|
|
181
|
+
#
|
|
182
|
+
# Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
|
|
183
|
+
# succeeds only when the scoped NOPASSWD grant from `yamine
|
|
184
|
+
# sudoers` is installed, and fails fast with guidance otherwise.
|
|
185
|
+
# Interactive runs use plain sudo (one password, then the service
|
|
186
|
+
# is installed for good).
|
|
187
|
+
#
|
|
188
|
+
# Returns true when the service is installed. No exit here: the
|
|
189
|
+
# bare `service install` CLI exits in `service`, while `setup`
|
|
190
|
+
# keeps going (hosts sync, doctor) after a successful install.
|
|
191
|
+
def service_install(ctx, args)
|
|
192
|
+
if ProxyControl.root?
|
|
193
|
+
install_service!(ctx)
|
|
194
|
+
elsif args.include?("--internal")
|
|
195
|
+
raise Error, "`service install --internal` is the root half of the sudo re-exec — run `yamine service install`"
|
|
196
|
+
else
|
|
197
|
+
puts "Installing system service (sudo required)..."
|
|
198
|
+
state = Certs.state_dir
|
|
199
|
+
cmd = ["env", "YAMINE_STATE_DIR=#{state}",
|
|
200
|
+
RbConfig.ruby, ProxyControl.bin_path,
|
|
201
|
+
"service", "install", "--internal"]
|
|
202
|
+
elevate(cmd)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def install_service!(ctx)
|
|
207
|
+
case RUBY_PLATFORM
|
|
208
|
+
when /darwin/ then install_launchd(ctx)
|
|
209
|
+
when /linux/ then install_systemd
|
|
210
|
+
else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
|
|
211
|
+
end
|
|
212
|
+
# Root can write /etc/hosts, so sync the routes registered so
|
|
213
|
+
# far while elevated — Safari works the moment setup finishes
|
|
214
|
+
# (Chrome resolves *.localhost natively).
|
|
215
|
+
sync_hosts_from_routes(ctx)
|
|
216
|
+
true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def sync_hosts_from_routes(ctx)
|
|
220
|
+
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
221
|
+
if hostnames.empty?
|
|
222
|
+
puts " No routes registered yet — hosts sync will happen on the next boot."
|
|
223
|
+
elsif Yamine::Hosts.sync(hostnames)
|
|
224
|
+
puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
|
|
225
|
+
else
|
|
226
|
+
warn " could not write /etc/hosts (run `sudo yamine hosts sync` later)"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Run a privileged command via sudo. Interactive: plain sudo (one
|
|
231
|
+
# prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
|
|
232
|
+
# NOPASSWD grant from `yamine sudoers`. On failure prints the
|
|
233
|
+
# provisioning hint so agents/CI know exactly what to install.
|
|
234
|
+
def elevate(cmd)
|
|
235
|
+
interactive = $stdin.tty? && ENV["CI"].nil?
|
|
236
|
+
sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
|
|
237
|
+
ok = Command.run(*sudo_args, *cmd)
|
|
238
|
+
return true if ok
|
|
239
|
+
|
|
240
|
+
# An interactive sudo failure is auth or the elevated command
|
|
241
|
+
# itself (whose error is already on screen) — re-run and read it.
|
|
242
|
+
# The scoped-grant hint only helps passwordless non-interactive
|
|
243
|
+
# runs, where a missing NOPASSWD rule is the usual cause.
|
|
244
|
+
if interactive
|
|
245
|
+
$stderr.puts "sudo failed — re-run `yamine setup` to try again."
|
|
246
|
+
else
|
|
247
|
+
$stderr.puts "sudo failed — install the scoped grant once:"
|
|
248
|
+
$stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
|
|
249
|
+
$stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
|
|
250
|
+
end
|
|
251
|
+
false
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def user_home_for_service
|
|
255
|
+
sudo_user = ENV["SUDO_USER"]
|
|
256
|
+
if sudo_user && !sudo_user.empty?
|
|
257
|
+
require "etc"
|
|
258
|
+
Etc.getpwnam(sudo_user).dir
|
|
259
|
+
else
|
|
260
|
+
Certs.home
|
|
261
|
+
end
|
|
262
|
+
rescue ArgumentError
|
|
263
|
+
Certs.home
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def install_launchd(ctx)
|
|
267
|
+
require "etc"
|
|
268
|
+
home = user_home_for_service
|
|
269
|
+
state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
|
|
270
|
+
dir = "/Library/LaunchDaemons"
|
|
271
|
+
FileUtils.mkdir_p(dir)
|
|
272
|
+
plist = <<~PLIST
|
|
273
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
274
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
275
|
+
<plist version="1.0">
|
|
276
|
+
<dict>
|
|
277
|
+
<key>Label</key><string>dev.ask.local</string>
|
|
278
|
+
<key>ProgramArguments</key>
|
|
279
|
+
<array>
|
|
280
|
+
<string>#{RbConfig.ruby}</string>
|
|
281
|
+
<string>#{ProxyControl.bin_path}</string>
|
|
282
|
+
<string>proxy</string><string>start</string><string>--foreground</string>
|
|
283
|
+
</array>
|
|
284
|
+
<key>EnvironmentVariables</key>
|
|
285
|
+
<dict>
|
|
286
|
+
<key>YAMINE_STATE_DIR</key><string>#{state_dir}</string>
|
|
287
|
+
<key>HOME</key><string>#{home}</string>
|
|
288
|
+
</dict>
|
|
289
|
+
<key>KeepAlive</key><true/>
|
|
290
|
+
<key>RunAtLoad</key><true/>
|
|
291
|
+
</dict>
|
|
292
|
+
</plist>
|
|
293
|
+
PLIST
|
|
294
|
+
path = File.join(dir, "dev.ask.local.plist")
|
|
295
|
+
File.write(path, plist)
|
|
296
|
+
File.chmod(0o644, path)
|
|
297
|
+
# launchd requires /Library/LaunchDaemons plists to be
|
|
298
|
+
# root-owned; we are root here (sudo re-exec). Enforce it
|
|
299
|
+
# explicitly: File.write keeps an existing file's owner, so a
|
|
300
|
+
# stale user-owned plist from an older version would otherwise
|
|
301
|
+
# survive the overwrite and bootstrap fails with error 5.
|
|
302
|
+
# Trust the CA into the System keychain while elevated: silent
|
|
303
|
+
# (no GUI popup) and trusted for every user on the machine.
|
|
304
|
+
File.chown(0, 0, path) if Process.uid.zero?
|
|
305
|
+
ensure_system_ca_trust
|
|
306
|
+
puts " Registering the launchd service on port 443..."
|
|
307
|
+
launchctl_bootstrap(path)
|
|
308
|
+
puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Root-only CA trust: the System keychain (all users, no prompt).
|
|
312
|
+
# Safe to call repeatedly — once the marker is set it is a no-op.
|
|
313
|
+
# Prints before doing work: the System keychain add can take a few
|
|
314
|
+
# seconds, and this runs in the root half of setup where silence
|
|
315
|
+
# reads as a hang.
|
|
316
|
+
def ensure_system_ca_trust
|
|
317
|
+
return if Certs.trusted?(Certs.state_dir)
|
|
318
|
+
|
|
319
|
+
puts " Trusting the CA into the System keychain..."
|
|
320
|
+
result = Trust.trust
|
|
321
|
+
warn " CA trust warning: #{result[:error]}" unless result[:trusted]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Modern launchctl system-domain verbs. The legacy `launchctl load`
|
|
325
|
+
# is rejected by current macOS with error 5 (Input/output error) —
|
|
326
|
+
# and worse, it can print that error while still exiting 0, so the
|
|
327
|
+
# old code "succeeded" without the service actually running.
|
|
328
|
+
# bootstrap/bootout are the supported verbs (same as puma-dev and
|
|
329
|
+
# portless); extracted so the command sequence is unit-testable.
|
|
330
|
+
def launchctl_bootstrap(path)
|
|
331
|
+
# Best-effort: booting out a service that was never loaded prints
|
|
332
|
+
# "Boot-out failed: 5" — nothing to clear then, so keep it quiet.
|
|
333
|
+
# Any real leftover is removed silently; bootstrap errors below
|
|
334
|
+
# stay loud.
|
|
335
|
+
Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
|
|
336
|
+
unless Command.run("launchctl", "bootstrap", "system", path)
|
|
337
|
+
raise Error, "launchctl bootstrap failed — check the plist at #{path}"
|
|
338
|
+
end
|
|
339
|
+
Command.run("launchctl", "enable", "system/dev.ask.local")
|
|
340
|
+
Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def launchctl_bootout(path)
|
|
344
|
+
Command.run("launchctl", "bootout", "system", path)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Pure unit-file builder (testable without root). Binds 80/443 at
|
|
348
|
+
# boot; the proxy runs with the invoking user's state dir.
|
|
349
|
+
def systemd_unit
|
|
350
|
+
home = user_home_for_service
|
|
351
|
+
state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
|
|
352
|
+
<<~UNIT
|
|
353
|
+
# /etc/systemd/system/yamine.service (binds 80/443 at boot)
|
|
354
|
+
[Unit]
|
|
355
|
+
After=network.target
|
|
356
|
+
|
|
357
|
+
[Service]
|
|
358
|
+
ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
|
|
359
|
+
Environment=YAMINE_STATE_DIR=#{state_dir}
|
|
360
|
+
Environment=HOME=#{home}
|
|
361
|
+
|
|
362
|
+
[Install]
|
|
363
|
+
WantedBy=multi-user.target
|
|
364
|
+
UNIT
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Install + start the systemd unit (mirrors portless). We are root
|
|
368
|
+
# here (sudo re-exec). The unit is written root-owned, then enabled
|
|
369
|
+
# and started.
|
|
370
|
+
def install_systemd
|
|
371
|
+
unit_path = "/etc/systemd/system/yamine.service"
|
|
372
|
+
File.write(unit_path, systemd_unit)
|
|
373
|
+
File.chmod(0o644, unit_path)
|
|
374
|
+
File.chown(0, 0, unit_path) if Process.uid.zero?
|
|
375
|
+
puts " Registering the systemd service on port 443..."
|
|
376
|
+
Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
|
|
377
|
+
Command.run("systemctl", "enable", "--now", "yamine") or raise Error, "systemctl enable failed"
|
|
378
|
+
puts "Installed systemd service yamine on port 443."
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def service_uninstall(ctx)
|
|
382
|
+
if !ProxyControl.root?
|
|
383
|
+
puts "Removing system service (sudo required)..."
|
|
384
|
+
state = Certs.state_dir
|
|
385
|
+
cmd = ["env", "YAMINE_STATE_DIR=#{state}",
|
|
386
|
+
RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
|
|
387
|
+
return elevate(cmd)
|
|
388
|
+
end
|
|
389
|
+
case RUBY_PLATFORM
|
|
390
|
+
when /darwin/
|
|
391
|
+
path = "/Library/LaunchDaemons/dev.ask.local.plist"
|
|
392
|
+
launchctl_bootout(path)
|
|
393
|
+
FileUtils.rm_f(path)
|
|
394
|
+
puts "Removed root LaunchDaemon."
|
|
395
|
+
when /linux/
|
|
396
|
+
Command.run("systemctl", "disable", "--now", "yamine")
|
|
397
|
+
Command.run("systemctl", "daemon-reload")
|
|
398
|
+
FileUtils.rm_f("/etc/systemd/system/yamine.service")
|
|
399
|
+
puts "Removed systemd service yamine."
|
|
400
|
+
else
|
|
401
|
+
raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
|
|
402
|
+
end
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def service_status(ctx)
|
|
407
|
+
port = ProxyControl.proxy_port(ctx.store)
|
|
408
|
+
if port.nil? || !ProxyControl.listening?(port)
|
|
409
|
+
puts "Proxy not running."
|
|
410
|
+
elsif ProxyControl.ours?(port, tls: ProxyControl.proxy_tls(ctx.store))
|
|
411
|
+
puts "Proxy running on port #{port}."
|
|
412
|
+
else
|
|
413
|
+
puts "Port #{port} in use by another process."
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def init(ctx, _args)
|
|
419
|
+
config_path = File.join(Dir.pwd, Config::RELATIVE_PATH)
|
|
420
|
+
if File.file?(config_path)
|
|
421
|
+
$stderr.puts "config/local.yml already exists at #{config_path}"
|
|
422
|
+
return
|
|
423
|
+
end
|
|
424
|
+
FileUtils.mkdir_p(File.join(Dir.pwd, Config::RELATIVE_DIR))
|
|
425
|
+
|
|
426
|
+
# Migrate Procfile.dev / Procfile if present.
|
|
427
|
+
procfile = Yamine::Procfile.find_file(Dir.pwd)
|
|
428
|
+
service = Yamine::Sanitize.hostname_label(File.basename(Dir.pwd))
|
|
429
|
+
|
|
430
|
+
process_lines = []
|
|
431
|
+
if procfile
|
|
432
|
+
lines = Yamine::Procfile.parse_file(procfile)
|
|
433
|
+
lines.each do |l|
|
|
434
|
+
type = Yamine::Procfile.classify(l.name) == :background ? "false" : "true"
|
|
435
|
+
process_lines << " #{l.name}:"
|
|
436
|
+
process_lines << " cmd: #{l.command}"
|
|
437
|
+
process_lines << " proxy: #{type}"
|
|
438
|
+
process_lines << " # NOTE: compound line - run explicitly" if l.compound
|
|
439
|
+
end
|
|
440
|
+
else
|
|
441
|
+
# Default: a single web process. Detect Rails (Gemfile with
|
|
442
|
+
# rails) vs plain Rack (config.ru) and pick the right boot.
|
|
443
|
+
has_rails = File.file?(File.join(Dir.pwd, "Gemfile")) &&
|
|
444
|
+
File.read(File.join(Dir.pwd, "Gemfile")).match?(/gem ["']rails["']/)
|
|
445
|
+
cmd =
|
|
446
|
+
if has_rails
|
|
447
|
+
"bundle exec puma -b tcp://127.0.0.1:$PORT config.ru"
|
|
448
|
+
elsif File.file?(File.join(Dir.pwd, "config.ru"))
|
|
449
|
+
"puma -b tcp://127.0.0.1:$PORT config.ru"
|
|
450
|
+
else
|
|
451
|
+
"bin/rails server -p $PORT"
|
|
452
|
+
end
|
|
453
|
+
process_lines << " web:"
|
|
454
|
+
process_lines << " cmd: #{cmd}"
|
|
455
|
+
process_lines << " proxy: true"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
content = <<~YAML
|
|
459
|
+
# config/local.yml — yamine configuration (Kamal-style).
|
|
460
|
+
# This is the only source of truth. Run `yamine` to boot everything.
|
|
461
|
+
# Overlay variants with config/local.<variant>.yml.
|
|
462
|
+
|
|
463
|
+
service: #{service}
|
|
464
|
+
|
|
465
|
+
proxy:
|
|
466
|
+
tld: localhost
|
|
467
|
+
|
|
468
|
+
processes:
|
|
469
|
+
#{process_lines.join("\n")}
|
|
470
|
+
|
|
471
|
+
env:
|
|
472
|
+
clear:
|
|
473
|
+
RAILS_ENV: development
|
|
474
|
+
YAML
|
|
475
|
+
File.write(config_path, content)
|
|
476
|
+
puts "Created #{config_path}"
|
|
477
|
+
puts " service: #{service}"
|
|
478
|
+
puts " processes from: #{procfile || 'defaults'}"
|
|
479
|
+
puts "Run `yamine start` to boot."
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def setup(ctx, args)
|
|
484
|
+
if args.include?("--help") || args.include?("-h")
|
|
485
|
+
puts <<~HELP
|
|
486
|
+
Usage: yamine setup [--no-service]
|
|
487
|
+
|
|
488
|
+
One-shot workstation setup for clean https://<app>.localhost URLs:
|
|
489
|
+
|
|
490
|
+
Default: install the root proxy service on 443 — runs under
|
|
491
|
+
sudo ONCE, trusting the CA system-wide in the same step
|
|
492
|
+
(no separate GUI authorization popup).
|
|
493
|
+
--no-service: trust the CA at user level, then run a sudo
|
|
494
|
+
daemon instead (no boot persistence; ephemeral machines).
|
|
495
|
+
|
|
496
|
+
Both finish by syncing /etc/hosts and verifying with doctor.
|
|
497
|
+
HELP
|
|
498
|
+
return
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
puts "Note: setup takes a few seconds while the 443 service installs and starts."
|
|
502
|
+
|
|
503
|
+
no_service = args.include?("--no-service")
|
|
504
|
+
steps = no_service ? 4 : 3
|
|
505
|
+
|
|
506
|
+
if no_service
|
|
507
|
+
step("1/#{steps} Trusting local CA") do
|
|
508
|
+
result = Yamine::Trust.trust
|
|
509
|
+
unless result[:trusted]
|
|
510
|
+
abort_setup("CA trust failed: #{result[:error]}",
|
|
511
|
+
"Run `yamine trust` manually to see the underlying error,",
|
|
512
|
+
"then re-run `yamine setup`.")
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
step("2/#{steps} Starting proxy sudo daemon on port 443") do
|
|
516
|
+
unless ensure_sudo_daemon(ctx)
|
|
517
|
+
abort_setup("Could not start the proxy daemon on port 443.",
|
|
518
|
+
"Check the log, then re-run `yamine setup`.")
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
else
|
|
522
|
+
step("1/#{steps} Installing proxy service on port 443 (trusts CA)") do
|
|
523
|
+
# Runs under sudo once; inside, the CA is trusted into the
|
|
524
|
+
# System keychain silently (no GUI popup) and the launchd
|
|
525
|
+
# service is bootstrapped. One password entry, that's all.
|
|
526
|
+
unless ensure_root_service(ctx)
|
|
527
|
+
abort_setup("Could not install the proxy service.",
|
|
528
|
+
"Fallback: `yamine setup --no-service` for a sudo daemon",
|
|
529
|
+
"without boot persistence.")
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
|
|
535
|
+
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
536
|
+
next if hostnames.empty?
|
|
537
|
+
|
|
538
|
+
# The root service install already synced under elevation; a
|
|
539
|
+
# plain re-run must not fail rewriting /etc/hosts unprivileged
|
|
540
|
+
# when the block is already in place.
|
|
541
|
+
unless Yamine::Hosts.synced?(hostnames) || Yamine::Hosts.sync(hostnames)
|
|
542
|
+
abort_setup("Could not write /etc/hosts.",
|
|
543
|
+
"Run `sudo yamine hosts sync`, then re-run `yamine setup`.")
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
step("#{no_service ? 4 : 3}/#{steps} Verifying with doctor") do
|
|
548
|
+
failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
|
|
549
|
+
if failed.zero?
|
|
550
|
+
puts "\nSetup complete: https://<app>.localhost URLs are ready."
|
|
551
|
+
puts "Try it: cd ~/code/myapp && yamine"
|
|
552
|
+
else
|
|
553
|
+
abort_setup("Doctor reports #{failed} failing check(s) (see above).",
|
|
554
|
+
"Fix the reported issues, then re-run `yamine setup`.")
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def step(label)
|
|
560
|
+
puts "\n==> #{label}..."
|
|
561
|
+
yield
|
|
562
|
+
puts " ok"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def abort_setup(problem, *fixes)
|
|
566
|
+
$stderr.puts "\nSetup failed: #{problem}"
|
|
567
|
+
fixes.each { |f| $stderr.puts " #{f}" }
|
|
568
|
+
exit 1
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# The two ways to get a privileged proxy on 443: a human runs setup
|
|
572
|
+
# once (interactive sudo), or an agent/CI image is pre-provisioned
|
|
573
|
+
# with the scoped NOPASSWD rules from `yamine sudoers`.
|
|
574
|
+
def privileged_port_hint
|
|
575
|
+
[
|
|
576
|
+
"Human: run this once — yamine setup",
|
|
577
|
+
"Agent/CI: pre-provision passwordless sudo once —",
|
|
578
|
+
" yamine sudoers > /tmp/yamine.sudoers",
|
|
579
|
+
" sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
|
|
580
|
+
]
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Install the root service (boot-persistent). Returns true when a
|
|
584
|
+
# proxy is up on 443 afterwards, false otherwise. Never falls back
|
|
585
|
+
# to a high port silently: a :port suffix in URLs would corrupt the
|
|
586
|
+
# stable-URL promise, so failure here is a hard error with guidance.
|
|
587
|
+
def ensure_root_service(ctx)
|
|
588
|
+
return false unless service_install(ctx, [])
|
|
589
|
+
wait_for_ours(ctx, 443, tls: true)
|
|
590
|
+
rescue Error, SystemCallError => e
|
|
591
|
+
warn " service install failed: #{e.message}"
|
|
592
|
+
false
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Sudo daemon for 443 without boot persistence (--no-service).
|
|
596
|
+
def ensure_sudo_daemon(ctx)
|
|
597
|
+
port, tls = 443, true
|
|
598
|
+
unless ctx.interactive?
|
|
599
|
+
warn " no TTY available for the sudo prompt."
|
|
600
|
+
warn " Agent/CI: pre-provision passwordless sudo once —"
|
|
601
|
+
warn " yamine sudoers > /tmp/yamine.sudoers"
|
|
602
|
+
warn " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
|
|
603
|
+
return false
|
|
604
|
+
end
|
|
605
|
+
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
|
|
606
|
+
wait_for_ours(ctx, port, tls: tls)
|
|
607
|
+
rescue Yamine::ProxyNotRunningError, SystemCallError => e
|
|
608
|
+
warn " daemon start failed: #{e.message.lines.first&.strip}"
|
|
609
|
+
false
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Poll until our proxy answers on the port. The freshly installed
|
|
613
|
+
# service takes a few seconds to boot, so show motion instead of a
|
|
614
|
+
# frozen prompt: a spinner on a terminal, dots elsewhere. Silent
|
|
615
|
+
# when the proxy is already up.
|
|
616
|
+
def wait_for_ours(ctx, port, tls:, timeout: 20)
|
|
617
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
618
|
+
terminal = $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
619
|
+
waiting = false
|
|
620
|
+
frame = 0
|
|
621
|
+
ok = false
|
|
622
|
+
loop do
|
|
623
|
+
if ProxyControl.ours?(port, tls: tls)
|
|
624
|
+
ok = true
|
|
625
|
+
break
|
|
626
|
+
end
|
|
627
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
628
|
+
|
|
629
|
+
unless waiting
|
|
630
|
+
print terminal ? " starting the proxy on port #{port} " : " (starting the proxy on port #{port}"
|
|
631
|
+
waiting = true
|
|
632
|
+
end
|
|
633
|
+
if terminal
|
|
634
|
+
print %w[| / - \\][frame % 4], "\b"
|
|
635
|
+
else
|
|
636
|
+
print "."
|
|
637
|
+
end
|
|
638
|
+
$stdout.flush
|
|
639
|
+
frame += 1
|
|
640
|
+
sleep 0.5
|
|
641
|
+
end
|
|
642
|
+
if waiting
|
|
643
|
+
if terminal
|
|
644
|
+
puts(ok ? "\r proxy is up on port #{port}." : "\r still not up on port #{port}.")
|
|
645
|
+
else
|
|
646
|
+
puts ")"
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
ok
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# yamine start — one-setup-and-go: idempotent workstation setup
|
|
653
|
+
# (trust, 443, hosts) when doctor fails, then boots the app in the
|
|
654
|
+
# current directory. The single command you run day-to-day; existing
|
|
655
|
+
# bare `yamine` keeps working via BootCommand.run_inferred, and
|
|
656
|
+
# `setup` stays for explicit re-setup.
|
|
657
|
+
def start(ctx, args)
|
|
658
|
+
if args.include?("--help") || args.include?("-h")
|
|
659
|
+
puts <<~HELP
|
|
660
|
+
Usage: yamine start [name] [cmd...] [options]
|
|
661
|
+
|
|
662
|
+
One-setup-and-go: if the workstation isn't ready (CA, proxy,
|
|
663
|
+
hosts), runs the minimal needed setup first, then boots the
|
|
664
|
+
app in the current directory.
|
|
665
|
+
|
|
666
|
+
yamine start # infer name, boot -> https://<app>.localhost
|
|
667
|
+
yamine start myapp # explicit name
|
|
668
|
+
yamine start -- --help # pass --help to the app, not here
|
|
669
|
+
|
|
670
|
+
Options are passed through to the boot path:
|
|
671
|
+
--name <name> --service <svc> --variant <v> --tld <tld> --branch
|
|
672
|
+
|
|
673
|
+
Setup failures become hard errors pointing at `yamine setup`;
|
|
674
|
+
non-interactive CI without a running proxy exits immediately.
|
|
675
|
+
HELP
|
|
676
|
+
return
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Fast path: every doctor check passes => skip setup entirely.
|
|
680
|
+
# This makes `start` as fast as `yamine` on a ready machine.
|
|
681
|
+
if needs_workstation_setup?(ctx)
|
|
682
|
+
ensure_workstation!(ctx)
|
|
683
|
+
end
|
|
684
|
+
BootCommand.run_inferred(ctx, args)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def needs_workstation_setup?(ctx)
|
|
688
|
+
Yamine::Doctor.run(store: ctx.store).any? { |c| !c.ok }
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# Quiet workstation setup for `start`: trust the CA, ensure a proxy
|
|
693
|
+
# on 443 (root service, sudo daemon fallback), and sync hosts.
|
|
694
|
+
# Each step is idempotent; only missing pieces run. Non-interactive
|
|
695
|
+
# CI without a proxy fails fast rather than prompting for sudo.
|
|
696
|
+
def ensure_workstation!(ctx)
|
|
697
|
+
# 1. CA
|
|
698
|
+
unless Yamine::Certs.trusted?(ctx.store.dir)
|
|
699
|
+
result = Yamine::Trust.trust
|
|
700
|
+
unless result[:trusted]
|
|
701
|
+
abort_setup("CA trust failed: #{result[:error]}",
|
|
702
|
+
"Run `yamine setup` in a terminal (handles trust + service),",
|
|
703
|
+
"then re-run `yamine start`.")
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# 2. Proxy on 443
|
|
708
|
+
port = 443
|
|
709
|
+
tls = true
|
|
710
|
+
unless Yamine::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
|
|
711
|
+
if port < 1024 && !Yamine::ProxyControl.root? && !ctx.interactive?
|
|
712
|
+
abort_setup("Proxy is not running and port 443 needs root to bind.", *privileged_port_hint)
|
|
713
|
+
end
|
|
714
|
+
ok =
|
|
715
|
+
if ProxyControl.root?
|
|
716
|
+
Yamine::CLI::SystemCommand.ensure_root_service(ctx)
|
|
717
|
+
elsif ctx.interactive?
|
|
718
|
+
begin
|
|
719
|
+
Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
|
|
720
|
+
wait_for_ours(ctx, port, tls: tls)
|
|
721
|
+
rescue Yamine::ProxyNotRunningError, SystemCallError => e
|
|
722
|
+
warn " daemon start failed: #{e.message.lines.first&.strip}"
|
|
723
|
+
false
|
|
724
|
+
end
|
|
725
|
+
else
|
|
726
|
+
false
|
|
727
|
+
end
|
|
728
|
+
unless ok
|
|
729
|
+
abort_setup("Proxy is not running and could not be started on port 443.", *privileged_port_hint)
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# 3. Hosts (best-effort: only needed for Safari; warn, don't fail)
|
|
734
|
+
unless Hosts.sync(ctx.store.load_routes.map { |r| r["hostname"] })
|
|
735
|
+
warn "Warning: could not write /etc/hosts (try sudo yamine hosts sync)."
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
# yamine kamal <variant> [--app myapp] [--domain preview.example.com]
|
|
739
|
+
def kamal(_ctx, args)
|
|
740
|
+
opts = {}
|
|
741
|
+
rest = []
|
|
742
|
+
i = 0
|
|
743
|
+
a = args.dup
|
|
744
|
+
while i < a.length
|
|
745
|
+
case a[i]
|
|
746
|
+
when "--app" then opts[:app] = a.fetch(i + 1); i += 2
|
|
747
|
+
when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
|
|
748
|
+
when "--tld" then opts[:tld] = a.fetch(i + 1); i += 2
|
|
749
|
+
else rest << a[i]; i += 1
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
variant = rest.first
|
|
753
|
+
raise Error, "Usage: yamine kamal <variant> [--app myapp] [--domain preview.example.com] [--tld <tld>]" unless variant
|
|
754
|
+
tld = opts[:tld] || ENV["YAMINE_TLD"]&.split(",")&.first || "localhost"
|
|
755
|
+
|
|
756
|
+
app = opts[:app] || Resolver.resolve(Dir.pwd).app
|
|
757
|
+
domain = opts[:domain] || ENV["YAMINE_KAMAL_DOMAIN"] || "preview.example.com"
|
|
758
|
+
slug = Sanitize.hostname_label(variant)
|
|
759
|
+
puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
|
|
760
|
+
puts "proxy:"
|
|
761
|
+
puts " ssl: true"
|
|
762
|
+
puts " hosts:"
|
|
763
|
+
puts " - #{app}-#{slug}.#{domain}"
|
|
764
|
+
puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
end
|