ask-local 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cab052002c8e0c03a4ed39fd2ff9b4d43474b6e2b969cd5ba0a5e5122c0229b
4
- data.tar.gz: 600b5127b34523e88d1fe8a08434309fd7690ab33217f7eb33fbf18b4d3f851b
3
+ metadata.gz: 4407cbb75f7a6a5347fd2b71963a4229dc925c7d36438ae885c25c04759c94a6
4
+ data.tar.gz: 3ac24692630768264da89e9ce37e75403cbb3e2686ce1c18d6b1a99483d9c85d
5
5
  SHA512:
6
- metadata.gz: 8451f072fa059698ec390057df2cc0945da03cb5bab03ef4e29cd0f93f437eba827df542814538d84464b1cb805e297d8ee104b36063c5a34ca761003a760922
7
- data.tar.gz: 9442146768560787bea3d9e1b024bdcd292db6af7a0611e029e6398c9863fb46589e58c7ca0e05950a71f0787e90df075f2c1c18984e9671638cd0348239e7c1
6
+ metadata.gz: '0419a2e0b1b62733f3687fffc1eafee535380aaa636a04d77814fc229b92521369fefe7b394f73533686b85a4c5975bc6e3837d985b3a387efff9b43dcd4f6d9'
7
+ data.tar.gz: b88ce440466e46a9c47af5a6ac4072d4ffc4ef774b17c4e07e531c4f9698b284edd364ec09d3467d05caa31eebadcd29a93796d7f01e400df4ea97787670a306
data/CHANGELOG.md CHANGED
@@ -1,69 +1,77 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.1] — 2026-09-06
3
+ ## [0.2.1] — 2026-09-08
4
4
 
5
- Patch release focused on workstation setup, URL correctness, and proxy reliability.
5
+ Patch release focused on making `ask-local setup` dependable end-to-end:
6
+ it now installs the 443 service and *finishes* (hosts + doctor), works
7
+ passwordless for agents, survives first-run edge cases, and shows what it
8
+ is doing while it works.
6
9
 
7
- ### Added — `ask-local setup` & `ask-local start`
10
+ ### Fixed
8
11
 
9
- - `ask-local setup` one-shot workstation setup for clean
10
- `https://<app>.localhost` URLs: trust the local CA, serve port 443
11
- (root launchd/systemd service when possible, sudo daemon otherwise),
12
- sync `/etc/hosts`, and verify with `doctor`. Each step reports
13
- `==>` / `ok` and the first failure aborts with the specific fix.
14
- - `ask-local start`one-setup-and-go entry point: an idempotent
15
- workstation-check-then-boot (`ask-local setup` if needed, then the
16
- app). `ask-local` bare is an alias for it; `ask-local setup` stays for
17
- explicit re-setup.
18
- - `askl` — shell-friendly alias binary (`bin/askl`, same entry point as
19
- `bin/ask-local`). Keep `ask-local` in logs and docs so `grep` stays
20
- useful.
12
+ - **`setup` no longer stops after installing the service.** The elevated
13
+ install used to `exit` mid-flow, so `/etc/hosts` was never synced and
14
+ `doctor` never ran despite the "setup complete" promise. Install and
15
+ uninstall now return booleans and the CLI owns the exit codes, so
16
+ `setup` always reaches the hosts and doctor steps. (`service uninstall`
17
+ also crashed outrightit called its handler without the required
18
+ context argument.)
19
+ - **A stale user-owned plist is healed on install.** `File.write` keeps
20
+ an existing file's owner, so a leftover plist from an older version
21
+ stayed user-owned and launchd refused it forever with "Bootstrap
22
+ failed: 5". Install now chowns the plist to root explicitly (launchd
23
+ and systemd).
24
+ - **The TLS proxy survives plaintext probes.** The health probe sends
25
+ plain HTTP before TLS; an unhandled `SSL_accept` error used to kill
26
+ the acceptor thread, and with both acceptors gone the daemon exited
27
+ before the readiness probe ever succeeded. A plaintext connection is
28
+ now just one dropped connection.
29
+ - `clean` referenced the CA common name unqualified (NameError) — fixed.
30
+ - `setup --no-service` printed a duplicate "2/3" step — numbered 1/4–4/4.
21
31
 
22
- ### Added — DNS-rebinding & log hygiene
32
+ ### Added
23
33
 
24
- - DNS-rebinding boundary: foreign `Host` headers get a bare 404 naming
25
- nothing; only hosts under our own configured TLDs see the route-listing
26
- 404. The proxy takes `--tld` (persisted to `proxy.tlds`) so the boundary
27
- follows custom domains. The `X-Ask-Local: 1` health header marks our
28
- proxy responses (including 404s) for the `ours?` probe.
29
- - Log rotation `proxy.log` and per-app backend logs rotate at 5MB
30
- (`ASK_LOCAL_LOG_MAX_BYTES`, one generation) before each write. A new
31
- `doctor` disk-usage check warns past 100MB of state.
34
+ - `ask-local sudoers` prints the scoped NOPASSWD rules so agents and
35
+ CI can install and run the 443 service without a TTY. Non-interactive
36
+ elevation uses `sudo -n` (never prompts) and points at the grant when
37
+ it is missing; interactive failures just say re-run.
38
+ - The root install syncs `/etc/hosts` under elevation, so Safari works
39
+ the moment setup finishes. `setup`'s hosts step verifies the block is
40
+ already present instead of failing unprivileged on re-runs, and
41
+ `ask-local hosts sync` re-runs itself elevated when the direct write
42
+ needs root.
43
+ - The CA is trusted into the System keychain silently while elevated
44
+ (all users, no GUI popup); unprivileged trust keeps the login
45
+ keychain.
46
+ - Progress UX: the root half of the install prints stage lines as it
47
+ works, and the proxy-start wait shows a rotating spinner on a terminal
48
+ (dots elsewhere), resolving to a clean "proxy is up" line.
32
49
 
33
- ### Fixed
50
+ ### Changed
34
51
 
35
- - **Silent `:1355` URL fallback removed.** Privileged-port (443) bind
36
- failure is now a hard error pointing at `ask-local setup`, never a
37
- booted app on `https://app.localhost:1355` that silently corrupts
38
- downstream consumers of `ASK_LOCAL_URL`. The only port-suffixed URLs
39
- are the ones you explicitly ask for (`proxy start -p 1355`).
40
- - **Health probe `130+?` hang fixed.** The TLS probe's `connect` sat
41
- outside the timeout: a TLS handshake against a foreign plain-HTTP
42
- server blocked in `connect` for 60s+. Connect is now inside the
43
- timeout, plain HTTP is tried first (our proxy answers plain HTTP via
44
- byte-peeking even on the TLS port), and any HTTP response without our
45
- header short-circuits as foreign — only silent servers wait for the
46
- timeout.
47
- - `start` dispatch was missing from the dispatcher despite being in
48
- `SUBCOMMANDS`, so `ask-local start` fell through to `run_named` with
49
- "start" as an app name. The kamal-help append drifted to a 6-space
50
- indent. Both are fixed and pinned by tests.
51
- - `base64` declared as a runtime dependency (it left the default gems in
52
- Ruby 3.4).
53
- - Missing `require "optparse"` lost in the CLI split.
52
+ - launchctl uses the modern system-domain verbs
53
+ (`bootstrap`/`bootout`/`enable`/`kickstart` the puma-dev/portless
54
+ pattern); the legacy `load`/`unload` are rejected by current macOS.
55
+ The best-effort pre-install bootout is silenced — its "Boot-out
56
+ failed: 5" noise on a first install looked like a failure.
57
+ - Every privileged execution path (sudo re-exec, launchctl, systemctl,
58
+ `security`) flows through one injectable `Command` seam, so the test
59
+ suite never shells out to real privileged commands.
54
60
 
55
- ### Tests — new coverage for this patch
61
+ ### Tests
56
62
 
57
- - `start_test.rb` help, fast-path vs. needs-setup branching, and the
58
- non-interactive hard-error message.
59
- - `setup_test.rb` four-step orchestration (all-steps-stubbed), first-failure
60
- abort with fix text, `--no-service` flag, and the three `ensure_proxy!`
61
- hard-error paths (non-interactive, foreign port, spawn failure) plus
62
- explicit-port URL honesty and responding-foreign-server fast classification.
63
- - Pinned under `bundle exec rake test` (fast unit suite); no `test:e2e`
64
- needed for these.
63
+ - Hermetic and CI-safe suite: no real sudo/security/launchctl in tests;
64
+ exact-argument expectations on the `Command` seam, source-pinned
65
+ regressions for the launchctl verbs, root-owned plist and stage
66
+ progress.
67
+ - New coverage: setup runs the real elevation path to "Setup complete"
68
+ (regression for the mid-flow exit), hosts-step write/verify/skip
69
+ behavior, `Hosts.synced?`, terminal spinner vs. off-tty dots, e2e
70
+ proof that a TLS daemon survives the plaintext probe, and both
71
+ elevation-failure hint paths. 182 unit + 23 e2e, all green.
65
72
 
66
- ## [0.2.0] — Unreleased
73
+
74
+ ## [0.2.0] — 2026-09-07
67
75
 
68
76
  ### Changed
69
77
 
@@ -129,6 +137,71 @@ Patch release focused on workstation setup, URL correctness, and proxy reliabili
129
137
  file checks now resolve against `destination_root`.
130
138
  - Port-flag injection no longer double-sets an explicit `$PORT`.
131
139
 
140
+
141
+ ## [0.1.1] — 2026-09-06
142
+
143
+ Patch release focused on workstation setup, URL correctness, and proxy reliability.
144
+
145
+ ### Added — `ask-local setup` & `ask-local start`
146
+
147
+ - `ask-local setup` — one-shot workstation setup for clean
148
+ `https://<app>.localhost` URLs: trust the local CA, serve port 443
149
+ (root launchd/systemd service when possible, sudo daemon otherwise),
150
+ sync `/etc/hosts`, and verify with `doctor`. Each step reports
151
+ `==>` / `ok` and the first failure aborts with the specific fix.
152
+ - `ask-local start` — one-setup-and-go entry point: an idempotent
153
+ workstation-check-then-boot (`ask-local setup` if needed, then the
154
+ app). `ask-local` bare is an alias for it; `ask-local setup` stays for
155
+ explicit re-setup.
156
+ - `askl` — shell-friendly alias binary (`bin/askl`, same entry point as
157
+ `bin/ask-local`). Keep `ask-local` in logs and docs so `grep` stays
158
+ useful.
159
+
160
+ ### Added — DNS-rebinding & log hygiene
161
+
162
+ - DNS-rebinding boundary: foreign `Host` headers get a bare 404 naming
163
+ nothing; only hosts under our own configured TLDs see the route-listing
164
+ 404. The proxy takes `--tld` (persisted to `proxy.tlds`) so the boundary
165
+ follows custom domains. The `X-Ask-Local: 1` health header marks our
166
+ proxy responses (including 404s) for the `ours?` probe.
167
+ - Log rotation — `proxy.log` and per-app backend logs rotate at 5MB
168
+ (`ASK_LOCAL_LOG_MAX_BYTES`, one generation) before each write. A new
169
+ `doctor` disk-usage check warns past 100MB of state.
170
+
171
+ ### Fixed
172
+
173
+ - **Silent `:1355` URL fallback removed.** Privileged-port (443) bind
174
+ failure is now a hard error pointing at `ask-local setup`, never a
175
+ booted app on `https://app.localhost:1355` that silently corrupts
176
+ downstream consumers of `ASK_LOCAL_URL`. The only port-suffixed URLs
177
+ are the ones you explicitly ask for (`proxy start -p 1355`).
178
+ - **Health probe `130+?` hang fixed.** The TLS probe's `connect` sat
179
+ outside the timeout: a TLS handshake against a foreign plain-HTTP
180
+ server blocked in `connect` for 60s+. Connect is now inside the
181
+ timeout, plain HTTP is tried first (our proxy answers plain HTTP via
182
+ byte-peeking even on the TLS port), and any HTTP response without our
183
+ header short-circuits as foreign — only silent servers wait for the
184
+ timeout.
185
+ - `start` dispatch was missing from the dispatcher despite being in
186
+ `SUBCOMMANDS`, so `ask-local start` fell through to `run_named` with
187
+ "start" as an app name. The kamal-help append drifted to a 6-space
188
+ indent. Both are fixed and pinned by tests.
189
+ - `base64` declared as a runtime dependency (it left the default gems in
190
+ Ruby 3.4).
191
+ - Missing `require "optparse"` lost in the CLI split.
192
+
193
+ ### Tests — new coverage for this patch
194
+
195
+ - `start_test.rb` — help, fast-path vs. needs-setup branching, and the
196
+ non-interactive hard-error message.
197
+ - `setup_test.rb` — four-step orchestration (all-steps-stubbed), first-failure
198
+ abort with fix text, `--no-service` flag, and the three `ensure_proxy!`
199
+ hard-error paths (non-interactive, foreign port, spawn failure) plus
200
+ explicit-port URL honesty and responding-foreign-server fast classification.
201
+ - Pinned under `bundle exec rake test` (fast unit suite); no `test:e2e`
202
+ needed for these.
203
+
204
+
132
205
  ## [0.1.0]
133
206
 
134
207
  ### Added
data/README.md CHANGED
@@ -28,6 +28,38 @@ The only port-suffixed URLs are the ones you explicitly ask for:
28
28
  `ask-local proxy start -p 1355` (CI/sandboxes where 443 is impossible).
29
29
  There the suffix is honest, and `ASK_LOCAL_URL` carries it faithfully.
30
30
 
31
+ ## Port 443: one-time setup, then never again
32
+
33
+ Binding 443 is privileged, so ask-local installs a **root-owned launchd
34
+ service** (macOS) or systemd unit (Linux) that binds 443 at boot — the
35
+ same model as puma-dev and portless. Installing it needs sudo **once per
36
+ machine**; after that, every `ask-local` run in any project gets a clean
37
+ `https://<app>.localhost` with no elevation and no prompt.
38
+
39
+ **Human (interactive):** run setup once — it trusts the CA, installs the
40
+ service, syncs hosts, and verifies:
41
+
42
+ ```bash
43
+ ask-local setup
44
+ ```
45
+
46
+ **Agent / CI (no TTY):** the same commands fail fast with guidance,
47
+ because sudo needs a terminal. To pre-provision a machine or image so
48
+ agents can install the service without a prompt, install the scoped
49
+ passwordless-sudo rules once (as an admin):
50
+
51
+ ```bash
52
+ ask-local sudoers > /tmp/ask-local.sudoers
53
+ sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local # macOS
54
+ sudo install -o root -g root -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local # Linux
55
+ ```
56
+
57
+ `ask-local sudoers` prints rules scoped to ask-local's own service
58
+ re-exec — the gem's exact ruby + bin path with the `service install
59
+ --internal` / `service uninstall --internal` subcommands — never a bare
60
+ interpreter. Re-run it after upgrading the gem if the install path
61
+ changes. To undo: `sudo rm /etc/sudoers.d/ask-local`.
62
+
31
63
  ## The one-file model
32
64
 
33
65
  Every app declares `config/local.yml` (Kamal-style) — the single source
@@ -223,7 +223,10 @@ module Ask
223
223
  privileged = port < 1024 && !ProxyControl.root?
224
224
  if privileged && !ctx.interactive?
225
225
  $stderr.puts "Error: proxy is not running and port #{port} needs root."
226
- $stderr.puts " Run this once: ask-local setup"
226
+ $stderr.puts " Human: run this once ask-local setup"
227
+ $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
228
+ $stderr.puts " ask-local sudoers > /tmp/ask-local.sudoers"
229
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
227
230
  $stderr.puts " Or start the proxy by hand: sudo ask-local proxy start"
228
231
  exit 1
229
232
  end
@@ -41,6 +41,15 @@ module Ask
41
41
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
42
42
  if Hosts.sync(hostnames)
43
43
  puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
44
+ elsif !ProxyControl.root? && !hostnames.empty?
45
+ # /etc/hosts is root-owned; once the root service is
46
+ # installed the guidance is "run ask-local hosts sync" — so
47
+ # make that command work by re-running it elevated.
48
+ puts "Writing /etc/hosts needs root — re-running elevated..."
49
+ state = Certs.state_dir
50
+ cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}", RbConfig.ruby,
51
+ ProxyControl.bin_path, "hosts", "sync"]
52
+ exit(elevate(cmd) ? 0 : 1)
44
53
  else
45
54
  $stderr.puts "Could not write /etc/hosts (try sudo)."
46
55
  exit 1
@@ -136,31 +145,111 @@ module Ask
136
145
  def service(ctx, args)
137
146
  sub = args.first
138
147
  case sub
139
- when "install" then service_install(ctx, args)
140
- when "uninstall" then service_uninstall
148
+ when "install" then exit(service_install(ctx, args) ? 0 : 1)
149
+ when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
141
150
  when "status" then service_status(ctx)
142
151
  else raise Error, "Usage: ask-local service [install|uninstall|status]"
143
152
  end
144
153
  end
145
154
 
155
+ # Print the scoped passwordless-sudo rules that let `service install`
156
+ # (and only it) run without a prompt. The service re-execs the whole
157
+ # gem under sudo, so the safe NOPASSWD grants exactly the gem path +
158
+ # subcommand for the current user — never a bare interpreter. This is
159
+ # how agents and repeat machines get clean :443 without a TTY.
160
+ #
161
+ # macOS: sudo install -o root -g wheel -m 440 <(ask-local sudoers) /etc/sudoers.d/ask-local
162
+ # Linux: sudo install -o root -g root -m 440 <(ask-local sudoers) /etc/sudoers.d/ask-local
163
+ def sudoers(_ctx, _args)
164
+ require "etc"
165
+ ruby = RbConfig.ruby
166
+ bin = ProxyControl.bin_path
167
+ user = ENV.fetch("USER", Etc.getlogin)
168
+ puts <<~SUDOERS
169
+ # ask-local: let #{user} install/run the privileged proxy on port 443
170
+ # without a password prompt. Scoped to ask-local's own service
171
+ # re-exec — the gem path above, not a bare interpreter.
172
+ #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
173
+ #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service uninstall --internal
174
+ SUDOERS
175
+ end
176
+
146
177
  # Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
147
- # Re-execs under sudo; the proxy runs with the invoking user's state
148
- # dir so routes registered by unprivileged CLIs are shared
149
- # (portless pattern). A root proxy can also write /etc/hosts.
178
+ # Non-root runs re-exec under sudo once (--internal marks the root
179
+ # half); the proxy runs with the invoking user's state dir so
180
+ # routes registered by unprivileged CLIs are shared. The root half
181
+ # can also write /etc/hosts.
182
+ #
183
+ # Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
184
+ # succeeds only when the scoped NOPASSWD grant from `ask-local
185
+ # sudoers` is installed, and fails fast with guidance otherwise.
186
+ # Interactive runs use plain sudo (one password, then the service
187
+ # is installed for good).
188
+ #
189
+ # Returns true when the service is installed. No exit here: the
190
+ # bare `service install` CLI exits in `service`, while `setup`
191
+ # keeps going (hosts sync, doctor) after a successful install.
150
192
  def service_install(ctx, args)
151
- if !ProxyControl.root? && !args.include?("--internal")
193
+ if ProxyControl.root?
194
+ install_service!(ctx)
195
+ elsif args.include?("--internal")
196
+ raise Error, "`service install --internal` is the root half of the sudo re-exec — run `ask-local service install`"
197
+ else
152
198
  puts "Installing system service (sudo required)..."
153
199
  state = Certs.state_dir
154
- ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
200
+ cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
155
201
  RbConfig.ruby, ProxyControl.bin_path,
156
- "service", "install", "--internal")
157
- exit(ok ? 0 : 1)
202
+ "service", "install", "--internal"]
203
+ elevate(cmd)
158
204
  end
205
+ end
206
+
207
+ def install_service!(ctx)
159
208
  case RUBY_PLATFORM
160
209
  when /darwin/ then install_launchd(ctx)
161
- when /linux/ then print_linux_unit
210
+ when /linux/ then install_systemd
162
211
  else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
163
212
  end
213
+ # Root can write /etc/hosts, so sync the routes registered so
214
+ # far while elevated — Safari works the moment setup finishes
215
+ # (Chrome resolves *.localhost natively).
216
+ sync_hosts_from_routes(ctx)
217
+ true
218
+ end
219
+
220
+ def sync_hosts_from_routes(ctx)
221
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
222
+ if hostnames.empty?
223
+ puts " No routes registered yet — hosts sync will happen on the next boot."
224
+ elsif Ask::Local::Hosts.sync(hostnames)
225
+ puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
226
+ else
227
+ warn " could not write /etc/hosts (run `sudo ask-local hosts sync` later)"
228
+ end
229
+ end
230
+
231
+ # Run a privileged command via sudo. Interactive: plain sudo (one
232
+ # prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
233
+ # NOPASSWD grant from `ask-local sudoers`. On failure prints the
234
+ # provisioning hint so agents/CI know exactly what to install.
235
+ def elevate(cmd)
236
+ interactive = $stdin.tty? && ENV["CI"].nil?
237
+ sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
238
+ ok = Command.run(*sudo_args, *cmd)
239
+ return true if ok
240
+
241
+ # An interactive sudo failure is auth or the elevated command
242
+ # itself (whose error is already on screen) — re-run and read it.
243
+ # The scoped-grant hint only helps passwordless non-interactive
244
+ # runs, where a missing NOPASSWD rule is the usual cause.
245
+ if interactive
246
+ $stderr.puts "sudo failed — re-run `ask-local setup` to try again."
247
+ else
248
+ $stderr.puts "sudo failed — install the scoped grant once:"
249
+ $stderr.puts " ask-local sudoers > /tmp/ask-local.sudoers"
250
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
251
+ end
252
+ false
164
253
  end
165
254
 
166
255
  def user_home_for_service
@@ -206,17 +295,62 @@ module Ask
206
295
  path = File.join(dir, "dev.ask.local.plist")
207
296
  File.write(path, plist)
208
297
  File.chmod(0o644, path)
209
- Ownership.chown_service_files(path)
210
- system("launchctl", "unload", path) rescue nil
211
- system("launchctl", "load", path) or raise Error, "launchctl load failed"
298
+ # launchd requires /Library/LaunchDaemons plists to be
299
+ # root-owned; we are root here (sudo re-exec). Enforce it
300
+ # explicitly: File.write keeps an existing file's owner, so a
301
+ # stale user-owned plist from an older version would otherwise
302
+ # survive the overwrite and bootstrap fails with error 5.
303
+ # Trust the CA into the System keychain while elevated: silent
304
+ # (no GUI popup) and trusted for every user on the machine.
305
+ File.chown(0, 0, path) if Process.uid.zero?
306
+ ensure_system_ca_trust
307
+ puts " Registering the launchd service on port 443..."
308
+ launchctl_bootstrap(path)
212
309
  puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
213
- puts "Restart your machine or run: sudo launchctl load #{path}"
214
310
  end
215
311
 
216
- def print_linux_unit
312
+ # Root-only CA trust: the System keychain (all users, no prompt).
313
+ # Safe to call repeatedly — once the marker is set it is a no-op.
314
+ # Prints before doing work: the System keychain add can take a few
315
+ # seconds, and this runs in the root half of setup where silence
316
+ # reads as a hang.
317
+ def ensure_system_ca_trust
318
+ return if Certs.trusted?(Certs.state_dir)
319
+
320
+ puts " Trusting the CA into the System keychain..."
321
+ result = Trust.trust
322
+ warn " CA trust warning: #{result[:error]}" unless result[:trusted]
323
+ end
324
+
325
+ # Modern launchctl system-domain verbs. The legacy `launchctl load`
326
+ # is rejected by current macOS with error 5 (Input/output error) —
327
+ # and worse, it can print that error while still exiting 0, so the
328
+ # old code "succeeded" without the service actually running.
329
+ # bootstrap/bootout are the supported verbs (same as puma-dev and
330
+ # portless); extracted so the command sequence is unit-testable.
331
+ def launchctl_bootstrap(path)
332
+ # Best-effort: booting out a service that was never loaded prints
333
+ # "Boot-out failed: 5" — nothing to clear then, so keep it quiet.
334
+ # Any real leftover is removed silently; bootstrap errors below
335
+ # stay loud.
336
+ Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
337
+ unless Command.run("launchctl", "bootstrap", "system", path)
338
+ raise Error, "launchctl bootstrap failed — check the plist at #{path}"
339
+ end
340
+ Command.run("launchctl", "enable", "system/dev.ask.local")
341
+ Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
342
+ end
343
+
344
+ def launchctl_bootout(path)
345
+ Command.run("launchctl", "bootout", "system", path)
346
+ end
347
+
348
+ # Pure unit-file builder (testable without root). Binds 80/443 at
349
+ # boot; the proxy runs with the invoking user's state dir.
350
+ def systemd_unit
217
351
  home = user_home_for_service
218
352
  state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
219
- puts <<~UNIT
353
+ <<~UNIT
220
354
  # /etc/systemd/system/ask-local.service (binds 80/443 at boot)
221
355
  [Unit]
222
356
  After=network.target
@@ -228,28 +362,46 @@ module Ask
228
362
 
229
363
  [Install]
230
364
  WantedBy=multi-user.target
231
-
232
- Install with: sudo cp ask-local.service /etc/systemd/system/ && sudo systemctl enable --now ask-local
233
- (Run that install command with sudo so the service is root-owned.)
234
365
  UNIT
235
366
  end
236
367
 
237
- def service_uninstall(_ctx)
368
+ # Install + start the systemd unit (mirrors portless). We are root
369
+ # here (sudo re-exec). The unit is written root-owned, then enabled
370
+ # and started.
371
+ def install_systemd
372
+ unit_path = "/etc/systemd/system/ask-local.service"
373
+ File.write(unit_path, systemd_unit)
374
+ File.chmod(0o644, unit_path)
375
+ File.chown(0, 0, unit_path) if Process.uid.zero?
376
+ puts " Registering the systemd service on port 443..."
377
+ Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
378
+ Command.run("systemctl", "enable", "--now", "ask-local") or raise Error, "systemctl enable failed"
379
+ puts "Installed systemd service ask-local on port 443."
380
+ end
381
+
382
+ def service_uninstall(ctx)
238
383
  if !ProxyControl.root?
384
+ puts "Removing system service (sudo required)..."
239
385
  state = Certs.state_dir
240
- ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
241
- RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal")
242
- exit(ok ? 0 : 1)
386
+ cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
387
+ RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
388
+ return elevate(cmd)
243
389
  end
244
390
  case RUBY_PLATFORM
245
391
  when /darwin/
246
392
  path = "/Library/LaunchDaemons/dev.ask.local.plist"
247
- system("launchctl", "unload", path) rescue nil
393
+ launchctl_bootout(path)
248
394
  FileUtils.rm_f(path)
249
395
  puts "Removed root LaunchDaemon."
396
+ when /linux/
397
+ Command.run("systemctl", "disable", "--now", "ask-local")
398
+ Command.run("systemctl", "daemon-reload")
399
+ FileUtils.rm_f("/etc/systemd/system/ask-local.service")
400
+ puts "Removed systemd service ask-local."
250
401
  else
251
- puts "Remove /etc/systemd/system/ask-local.service, then: sudo systemctl disable --now ask-local"
402
+ raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
252
403
  end
404
+ true
253
405
  end
254
406
 
255
407
  def service_status(ctx)
@@ -336,52 +488,64 @@ module Ask
336
488
 
337
489
  One-shot workstation setup for clean https://<app>.localhost URLs:
338
490
 
339
- 1. Trust the local CA (no more browser warnings)
340
- 2. Serve port 443 (root service at boot, or sudo daemon now)
341
- 3. Sync /etc/hosts (Safari + custom TLDs)
342
- 4. Verify everything with doctor
491
+ Default: install the root proxy service on 443 — runs under
492
+ sudo ONCE, trusting the CA system-wide in the same step
493
+ (no separate GUI authorization popup).
494
+ --no-service: trust the CA at user level, then run a sudo
495
+ daemon instead (no boot persistence; ephemeral machines).
343
496
 
344
- --no-service skips the root service and starts a sudo daemon
345
- instead (no boot persistence; good for ephemeral machines).
497
+ Both finish by syncing /etc/hosts and verifying with doctor.
346
498
  HELP
347
499
  return
348
500
  end
349
501
 
350
- step("1/4 Trusting local CA") do
351
- result = Ask::Local::Trust.trust
352
- unless result[:trusted]
353
- abort_setup("CA trust failed: #{result[:error]}",
354
- "Run `ask-local trust` manually to see the underlying error,",
355
- "then re-run `ask-local setup`.")
356
- end
357
- end
502
+ puts "Note: setup takes a few seconds while the 443 service installs and starts."
358
503
 
359
- unless args.include?("--no-service")
360
- step("2/4 Installing proxy service on port 443") do
361
- unless ensure_root_service(ctx)
362
- abort_setup("Could not install the proxy service.",
363
- "Fallback: `ask-local setup --no-service` for a sudo daemon",
364
- "without boot persistence.")
504
+ no_service = args.include?("--no-service")
505
+ steps = no_service ? 4 : 3
506
+
507
+ if no_service
508
+ step("1/#{steps} Trusting local CA") do
509
+ result = Ask::Local::Trust.trust
510
+ unless result[:trusted]
511
+ abort_setup("CA trust failed: #{result[:error]}",
512
+ "Run `ask-local trust` manually to see the underlying error,",
513
+ "then re-run `ask-local setup`.")
365
514
  end
366
515
  end
367
- else
368
- step("2/4 Starting proxy sudo daemon on port 443") do
516
+ step("2/#{steps} Starting proxy sudo daemon on port 443") do
369
517
  unless ensure_sudo_daemon(ctx)
370
518
  abort_setup("Could not start the proxy daemon on port 443.",
371
519
  "Check the log, then re-run `ask-local setup`.")
372
520
  end
373
521
  end
522
+ else
523
+ step("1/#{steps} Installing proxy service on port 443 (trusts CA)") do
524
+ # Runs under sudo once; inside, the CA is trusted into the
525
+ # System keychain silently (no GUI popup) and the launchd
526
+ # service is bootstrapped. One password entry, that's all.
527
+ unless ensure_root_service(ctx)
528
+ abort_setup("Could not install the proxy service.",
529
+ "Fallback: `ask-local setup --no-service` for a sudo daemon",
530
+ "without boot persistence.")
531
+ end
532
+ end
374
533
  end
375
534
 
376
- step("3/4 Syncing /etc/hosts") do
535
+ step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
377
536
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
378
- unless Ask::Local::Hosts.sync(hostnames)
537
+ next if hostnames.empty?
538
+
539
+ # The root service install already synced under elevation; a
540
+ # plain re-run must not fail rewriting /etc/hosts unprivileged
541
+ # when the block is already in place.
542
+ unless Ask::Local::Hosts.synced?(hostnames) || Ask::Local::Hosts.sync(hostnames)
379
543
  abort_setup("Could not write /etc/hosts.",
380
544
  "Run `sudo ask-local hosts sync`, then re-run `ask-local setup`.")
381
545
  end
382
546
  end
383
547
 
384
- step("4/4 Verifying with doctor") do
548
+ step("#{no_service ? 4 : 3}/#{steps} Verifying with doctor") do
385
549
  failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
386
550
  if failed.zero?
387
551
  puts "\nSetup complete: https://<app>.localhost URLs are ready."
@@ -405,12 +569,24 @@ module Ask
405
569
  exit 1
406
570
  end
407
571
 
572
+ # The two ways to get a privileged proxy on 443: a human runs setup
573
+ # once (interactive sudo), or an agent/CI image is pre-provisioned
574
+ # with the scoped NOPASSWD rules from `ask-local sudoers`.
575
+ def privileged_port_hint
576
+ [
577
+ "Human: run this once — ask-local setup",
578
+ "Agent/CI: pre-provision passwordless sudo once —",
579
+ " ask-local sudoers > /tmp/ask-local.sudoers",
580
+ " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
581
+ ]
582
+ end
583
+
408
584
  # Install the root service (boot-persistent). Returns true when a
409
585
  # proxy is up on 443 afterwards, false otherwise. Never falls back
410
586
  # to a high port silently: a :port suffix in URLs would corrupt the
411
587
  # stable-URL promise, so failure here is a hard error with guidance.
412
588
  def ensure_root_service(ctx)
413
- service_install(ctx, [])
589
+ return false unless service_install(ctx, [])
414
590
  wait_for_ours(ctx, 443, tls: true)
415
591
  rescue Error, SystemCallError => e
416
592
  warn " service install failed: #{e.message}"
@@ -422,6 +598,9 @@ module Ask
422
598
  port, tls = 443, true
423
599
  unless ctx.interactive?
424
600
  warn " no TTY available for the sudo prompt."
601
+ warn " Agent/CI: pre-provision passwordless sudo once —"
602
+ warn " ask-local sudoers > /tmp/ask-local.sudoers"
603
+ warn " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
425
604
  return false
426
605
  end
427
606
  ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
@@ -431,14 +610,44 @@ module Ask
431
610
  false
432
611
  end
433
612
 
613
+ # Poll until our proxy answers on the port. The freshly installed
614
+ # service takes a few seconds to boot, so show motion instead of a
615
+ # frozen prompt: a spinner on a terminal, dots elsewhere. Silent
616
+ # when the proxy is already up.
434
617
  def wait_for_ours(ctx, port, tls:, timeout: 20)
435
618
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
436
- until ProxyControl.ours?(port, tls: tls)
437
- return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
619
+ terminal = $stdout.respond_to?(:tty?) && $stdout.tty?
620
+ waiting = false
621
+ frame = 0
622
+ ok = false
623
+ loop do
624
+ if ProxyControl.ours?(port, tls: tls)
625
+ ok = true
626
+ break
627
+ end
628
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
438
629
 
630
+ unless waiting
631
+ print terminal ? " starting the proxy on port #{port} " : " (starting the proxy on port #{port}"
632
+ waiting = true
633
+ end
634
+ if terminal
635
+ print %w[| / - \\][frame % 4], "\b"
636
+ else
637
+ print "."
638
+ end
639
+ $stdout.flush
640
+ frame += 1
439
641
  sleep 0.5
440
642
  end
441
- true
643
+ if waiting
644
+ if terminal
645
+ puts(ok ? "\r proxy is up on port #{port}." : "\r still not up on port #{port}.")
646
+ else
647
+ puts ")"
648
+ end
649
+ end
650
+ ok
442
651
  end
443
652
 
444
653
  # ask-local start — one-setup-and-go: idempotent workstation setup
@@ -501,8 +710,7 @@ module Ask
501
710
  tls = true
502
711
  unless Ask::Local::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
503
712
  if port < 1024 && !Ask::Local::ProxyControl.root? && !ctx.interactive?
504
- abort_setup("Proxy is not running and port 443 needs root to bind.",
505
- "Run this once in a terminal: ask-local setup")
713
+ abort_setup("Proxy is not running and port 443 needs root to bind.", *privileged_port_hint)
506
714
  end
507
715
  ok =
508
716
  if ProxyControl.root?
@@ -519,9 +727,7 @@ module Ask
519
727
  false
520
728
  end
521
729
  unless ok
522
- abort_setup("Proxy is not running and could not be started on port 443.",
523
- "Run `ask-local setup` in a terminal (it handles trust + service + hosts),",
524
- "then re-run `ask-local start`.")
730
+ abort_setup("Proxy is not running and could not be started on port 443.", *privileged_port_hint)
525
731
  end
526
732
  end
527
733
 
data/lib/ask/local/cli.rb CHANGED
@@ -11,7 +11,7 @@ module Ask
11
11
  # In non-interactive environments (no TTY or CI=1) we fail early with
12
12
  # a clear message instead of prompting (portless lesson).
13
13
  class CLI
14
- SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open setup start init].freeze
14
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
15
15
 
16
16
  def self.run(argv)
17
17
  new.run(argv)
@@ -44,6 +44,7 @@ module Ask
44
44
  when "prune" then RoutesCommand.prune(ctx, args)
45
45
  when "proxy" then SystemCommand.proxy(ctx, args)
46
46
  when "service" then SystemCommand.service(ctx, args)
47
+ when "sudoers" then SystemCommand.sudoers(ctx, args)
47
48
  when "setup" then SystemCommand.setup(ctx, args)
48
49
  when "init" then SystemCommand.init(ctx, args)
49
50
  when "start" then SystemCommand.start(ctx, args)
@@ -83,6 +84,7 @@ module Ask
83
84
  ask-local prune Remove stale routes
84
85
  ask-local proxy start|stop Control the proxy
85
86
  ask-local service install|status|uninstall OS startup service
87
+ ask-local sudoers Print scoped passwordless-sudo rules for port 443
86
88
  ask-local hosts sync|clean Manage /etc/hosts entries
87
89
  ask-local kamal <variant> Preview-deploy snippet for Kamal
88
90
  ask-local stop Stop this app's backend + routes
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ask
6
+ module Local
7
+ # Injectable command runner for privileged/child-process work.
8
+ #
9
+ # Every system()/Open3 call that tests must control goes through here
10
+ # (elevation re-exec, launchctl, systemctl, security). Unit tests stub
11
+ # Command.run / Command.capture2 so they never shell out — no real
12
+ # sudo prompt, no keychain mutation, hermetic and CI-safe. The real
13
+ # implementations are the thin wrappers below.
14
+ #
15
+ # A plain class (not module_function): Mocha stubs class methods
16
+ # reliably, whereas module_function singletons dodge the stub and the
17
+ # real command would run.
18
+ class Command
19
+ # True when the command exited 0. Mirrors Kernel#system semantics.
20
+ def self.run(*args)
21
+ system(*args)
22
+ end
23
+
24
+ # [stdout, Process::Status] — mirrors Open3.capture2.
25
+ def self.capture2(*args)
26
+ Open3.capture2(*args)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -56,6 +56,14 @@ module Ask
56
56
  false
57
57
  end
58
58
 
59
+ # True when /etc/hosts already carries exactly the managed block for
60
+ # these hostnames. The root service install syncs under elevation;
61
+ # plain re-runs of setup must not fail trying to rewrite it
62
+ # unprivileged when nothing changed.
63
+ def synced?(hostnames, path = PATH)
64
+ read(path).include?(managed_block(hostnames))
65
+ end
66
+
59
67
  def clean(path = PATH)
60
68
  content = read(path)
61
69
  updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
@@ -50,13 +50,6 @@ module Ask
50
50
  def chown_state_dir(dir)
51
51
  fix(dir)
52
52
  end
53
-
54
- # The LaunchDaemon plist root writes into /Library/LaunchDaemons:
55
- # root-owned is correct there, but record it so `service status`
56
- # can explain the split.
57
- def chown_service_files(*paths)
58
- fix(*paths)
59
- end
60
53
  end
61
54
  end
62
55
  end
@@ -64,6 +64,7 @@ module Ask
64
64
  servers.each { |s| s.listen(@port) }
65
65
  @port = servers.first.addr[1]
66
66
  Ownership.chown_state_dir(@store.dir)
67
+ ensure_system_ca_trust if Process.uid.zero?
67
68
  trap("INT") { stop(servers) }
68
69
  trap("TERM") do
69
70
  @supervisor&.shutdown
@@ -91,7 +92,7 @@ module Ask
91
92
  release
92
93
  end
93
94
  end
94
- rescue IOError, SystemCallError
95
+ rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
95
96
  break unless @running
96
97
  end
97
98
  end
@@ -124,6 +125,16 @@ module Ask
124
125
 
125
126
  private
126
127
 
128
+ # A root proxy (launchd service or sudo daemon) is the one process
129
+ # that can trust the CA into the System keychain silently — no GUI
130
+ # popup. Idempotent: the state-dir marker makes repeat boots a no-op.
131
+ def ensure_system_ca_trust
132
+ return if Certs.trusted?(@state_dir)
133
+
134
+ result = Trust.trust(@state_dir)
135
+ @on_error.call("CA trust warning: #{result[:error]}") unless result[:trusted]
136
+ end
137
+
127
138
  def admit?
128
139
  @inflight_mutex.synchronize do
129
140
  return false if @inflight >= @max_connections
@@ -32,14 +32,24 @@ module Ask
32
32
  end
33
33
 
34
34
  def trust_macos(cert_path)
35
- keychain = login_keychain
36
- _out, status = Open3.capture2("security", "add-trusted-cert",
37
- "-r", "trustRoot", "-k", keychain, cert_path)
38
- raise CertError, "security add-trusted-cert failed" unless status.success?
35
+ if Process.uid.zero?
36
+ # Running elevated (service install / root proxy): add to the
37
+ # System keychain with the admin (-d) domain. Root can modify it
38
+ # silently no GUI authorization popup, and every user's
39
+ # browsers trust the proxy.
40
+ _out, status = Command.capture2("security", "add-trusted-cert",
41
+ "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", cert_path)
42
+ raise CertError, "security add-trusted-cert (system) failed" unless status.success?
43
+ else
44
+ keychain = login_keychain
45
+ _out, status = Command.capture2("security", "add-trusted-cert",
46
+ "-r", "trustRoot", "-k", keychain, cert_path)
47
+ raise CertError, "security add-trusted-cert failed" unless status.success?
48
+ end
39
49
  end
40
50
 
41
51
  def login_keychain
42
- out, status = Open3.capture2("security", "default-keychain")
52
+ out, status = Command.capture2("security", "default-keychain")
43
53
  if status.success? && (m = out.match(/"(.+)"/))
44
54
  m[1]
45
55
  else
@@ -51,7 +61,7 @@ module Ask
51
61
  dest_dir, update_cmd = linux_ca_config
52
62
  FileUtils.mkdir_p(dest_dir)
53
63
  FileUtils.cp(cert_path, File.join(dest_dir, "ask-local-ca.crt"))
54
- _out, status = Open3.capture2(update_cmd)
64
+ _out, status = Command.capture2(update_cmd)
55
65
  raise CertError, "#{update_cmd} failed" unless status.success?
56
66
  end
57
67
 
@@ -73,7 +83,7 @@ module Ask
73
83
  end
74
84
 
75
85
  def trust_windows(cert_path)
76
- _out, status = Open3.capture2("certutil", "-addstore", "-user", "Root", cert_path)
86
+ _out, status = Command.capture2("certutil", "-addstore", "-user", "Root", cert_path)
77
87
  raise CertError, "certutil failed" unless status.success?
78
88
  end
79
89
 
@@ -86,12 +96,12 @@ module Ask
86
96
  errors = []
87
97
  case platform
88
98
  when :macos
89
- Open3.capture2("security", "remove-trusted-cert", paths[:cert])
99
+ Command.capture2("security", "remove-trusted-cert", paths[:cert])
90
100
  # delete-certificate fails silently when no match remains; loop
91
101
  # to clear duplicate CN entries from each keychain.
92
102
  [login_keychain, "/Library/Keychains/System.keychain"].each do |kc|
93
103
  5.times do
94
- Open3.capture2("security", "delete-certificate", "-c", CA_COMMON_NAME, kc)
104
+ Command.capture2("security", "delete-certificate", "-c", Certs::CA_COMMON_NAME, kc)
95
105
  end
96
106
  rescue SystemCallError
97
107
  nil
@@ -100,9 +110,9 @@ module Ask
100
110
  dest_dir, update_cmd = linux_ca_config
101
111
  dest = File.join(dest_dir, "ask-local-ca.crt")
102
112
  FileUtils.rm_f(dest) if File.file?(dest)
103
- Open3.capture2(update_cmd)
113
+ Command.capture2(update_cmd)
104
114
  when :windows
105
- Open3.capture2("certutil", "-delstore", "-user", "Root", CA_COMMON_NAME)
115
+ Command.capture2("certutil", "-delstore", "-user", "Root", Certs::CA_COMMON_NAME)
106
116
  end
107
117
  trusted_after = begin
108
118
  Certs.trusted?(dir)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Local
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/ask-local.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "ask/local/variant"
9
9
  require_relative "ask/local/framework"
10
10
  require_relative "ask/local/config"
11
11
  require_relative "ask/local/ownership"
12
+ require_relative "ask/local/command"
12
13
  require_relative "ask/local/log"
13
14
  require_relative "ask/local/route_store"
14
15
  require_relative "ask/local/certs"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-local
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -89,6 +89,7 @@ files:
89
89
  - lib/ask/local/cli/context.rb
90
90
  - lib/ask/local/cli/routes.rb
91
91
  - lib/ask/local/cli/system.rb
92
+ - lib/ask/local/command.rb
92
93
  - lib/ask/local/config.rb
93
94
  - lib/ask/local/doctor.rb
94
95
  - lib/ask/local/errors.rb