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 +4 -4
- data/CHANGELOG.md +127 -54
- data/README.md +32 -0
- data/lib/ask/local/cli/boot.rb +4 -1
- data/lib/ask/local/cli/system.rb +265 -59
- data/lib/ask/local/cli.rb +3 -1
- data/lib/ask/local/command.rb +30 -0
- data/lib/ask/local/hosts.rb +8 -0
- data/lib/ask/local/ownership.rb +0 -7
- data/lib/ask/local/proxy.rb +12 -1
- data/lib/ask/local/trust.rb +21 -11
- data/lib/ask/local/version.rb +1 -1
- data/lib/ask-local.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4407cbb75f7a6a5347fd2b71963a4229dc925c7d36438ae885c25c04759c94a6
|
|
4
|
+
data.tar.gz: 3ac24692630768264da89e9ce37e75403cbb3e2686ce1c18d6b1a99483d9c85d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0419a2e0b1b62733f3687fffc1eafee535380aaa636a04d77814fc229b92521369fefe7b394f73533686b85a4c5975bc6e3837d985b3a387efff9b43dcd4f6d9'
|
|
7
|
+
data.tar.gz: b88ce440466e46a9c47af5a6ac4072d4ffc4ef774b17c4e07e531c4f9698b284edd364ec09d3467d05caa31eebadcd29a93796d7f01e400df4ea97787670a306
|
data/CHANGELOG.md
CHANGED
|
@@ -1,69 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.2.1] — 2026-09-08
|
|
4
4
|
|
|
5
|
-
Patch release focused on
|
|
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
|
-
###
|
|
10
|
+
### Fixed
|
|
8
11
|
|
|
9
|
-
-
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 outright — it 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
|
|
32
|
+
### Added
|
|
23
33
|
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
`
|
|
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
|
-
###
|
|
50
|
+
### Changed
|
|
34
51
|
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
61
|
+
### Tests
|
|
56
62
|
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/local/cli/boot.rb
CHANGED
|
@@ -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 "
|
|
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
|
data/lib/ask/local/cli/system.rb
CHANGED
|
@@ -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
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
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
|
|
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
|
-
|
|
200
|
+
cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
|
|
155
201
|
RbConfig.ruby, ProxyControl.bin_path,
|
|
156
|
-
"service", "install", "--internal"
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
|
535
|
+
step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
|
|
377
536
|
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
378
|
-
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/local/hosts.rb
CHANGED
|
@@ -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, "")
|
data/lib/ask/local/ownership.rb
CHANGED
|
@@ -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
|
data/lib/ask/local/proxy.rb
CHANGED
|
@@ -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
|
data/lib/ask/local/trust.rb
CHANGED
|
@@ -32,14 +32,24 @@ module Ask
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def trust_macos(cert_path)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
+
Command.capture2(update_cmd)
|
|
104
114
|
when :windows
|
|
105
|
-
|
|
115
|
+
Command.capture2("certutil", "-delstore", "-user", "Root", Certs::CA_COMMON_NAME)
|
|
106
116
|
end
|
|
107
117
|
trusted_after = begin
|
|
108
118
|
Certs.trusted?(dir)
|
data/lib/ask/local/version.rb
CHANGED
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.
|
|
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
|