@rubytech/create-maxy-code 0.1.109 → 0.1.110

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.
Files changed (30) hide show
  1. package/dist/index.js +11 -125
  2. package/dist/snap-chromium.js +1 -2
  3. package/dist/uninstall.js +10 -9
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  6. package/payload/platform/plugins/admin/mcp/dist/index.js +1 -1
  7. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/admin/skills/upgrade/SKILL.md +34 -0
  9. package/payload/platform/plugins/cloudflare/.claude-plugin/plugin.json +1 -1
  10. package/payload/platform/plugins/cloudflare/PLUGIN.md +9 -16
  11. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +7 -12
  12. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +3 -3
  14. package/payload/platform/plugins/cloudflare/references/manual-setup.md +16 -51
  15. package/payload/platform/plugins/cloudflare/references/reset-guide.md +24 -25
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +29 -149
  17. package/payload/platform/plugins/docs/references/admin-session.md +1 -1
  18. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  19. package/payload/platform/plugins/docs/references/cloudflare.md +20 -29
  20. package/payload/platform/plugins/docs/references/platform.md +1 -1
  21. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  22. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  23. package/payload/platform/scripts/check-no-task-id-leaks.mjs +1 -1
  24. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  25. package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +0 -241
  26. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +0 -98
  27. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +0 -715
  28. package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +0 -107
  29. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +0 -852
  30. package/payload/platform/plugins/cloudflare/scripts/tunnel-ingress.ts +0 -291
@@ -1,852 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Deterministic Cloudflare tunnel setup — mirrors
3
- # platform/plugins/cloudflare/references/manual-setup.md step-by-step.
4
- # Brand-scoped paths, user-space systemd, apex-aware.
5
- #
6
- # Usage:
7
- # setup-tunnel.sh <brand> <port> <hostname> [<hostname> ...]
8
- #
9
- # Example:
10
- # setup-tunnel.sh maxy 19200 admin.maxy.bot public.maxy.bot maxy.chat
11
- #
12
- # Apex hostnames (exactly two DNS labels) cannot have their CNAMEs created
13
- # via `cloudflared tunnel route dns` — see manual-setup.md §Step 4 Apex
14
- # hostnames. The script writes the ingress rule for them but prints an
15
- # explicit ACTION REQUIRED message naming the manual dashboard step.
16
- #
17
- # Step 1 surfaces the argotunnel URL on stdout as an `OAUTH_URL:` line. The
18
- # native Claude Code PTY linkifies bare URLs so the operator clicks it from
19
- # their admin chat directly. The script spawns no browser of its own.
20
- # cloudflared's stdout+stderr stream verbatim into the PTY chat alongside
21
- # the script's own `[setup-tunnel] step=<phase> …` phase lines.
22
-
23
- set -euo pipefail
24
-
25
- # Resolve symlinks before dirname — ~/setup-tunnel.sh is installed as a symlink
26
- # (see packages/create-maxy-code/src/index.ts:installTunnelScripts), so the raw
27
- # BASH_SOURCE[0] points at $HOME, not the scripts directory where the sibling
28
- # helper (tunnel-ingress.ts) lives.
29
- SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
30
-
31
- # --------------------------------------------------------------------------
32
- # Phase-line helper. Stream-log plumbing retired in Task 287 — every
33
- # observation goes straight to stdout for the PTY to render. Format is
34
- # `[<scope>] <key>=<value> …` to keep operator-grep parity with the prior
35
- # `[<scope>] phase=…` shape that the now-deleted _stream-log.sh emitted.
36
- # --------------------------------------------------------------------------
37
-
38
- phase_line() {
39
- local scope=$1
40
- shift
41
- printf '[%s] %s\n' "${scope}" "$*"
42
- }
43
-
44
- # --------------------------------------------------------------------------
45
- # Args
46
- # --------------------------------------------------------------------------
47
-
48
- if [ "$#" -lt 3 ]; then
49
- echo "Usage: $0 <brand> <port> <hostname> [<hostname> ...]" >&2
50
- echo "Example: $0 maxy 19200 admin.maxy.bot public.maxy.bot maxy.chat" >&2
51
- exit 2
52
- fi
53
-
54
- BRAND="$1"
55
- PORT="$2"
56
- shift 2
57
- HOSTNAMES=("$@")
58
-
59
- # --------------------------------------------------------------------------
60
- # SSH/SMB ingress env vars.
61
- #
62
- # SSH and SMB hostnames arrive via environment, NOT positional argv, so the
63
- # existing form/endpoint contract (which passes admin/public/apex as
64
- # positionals) keeps working unchanged. The action-runner sets these env
65
- # vars when the operator submits the cloudflare-setup form with the SSH
66
- # and SMB fields populated; absent env vars mean "no SSH/SMB ingress this
67
- # run" — except when tunnel.state already persists them, in which case
68
- # rehydrate from there.
69
- #
70
- # SSH_HOSTNAME e.g. ssh.maxy.bot → ingress service: ssh://localhost:22
71
- # SMB_HOSTNAME e.g. smb.maxy.bot → ingress service: tcp://localhost:445
72
- # OPERATOR_EMAIL e.g. joel@x.com → printed in the ACTION REQUIRED
73
- # dashboard click-path for the
74
- # Access policy (operator-authored;
75
- # CF API is banned per
76
- # feedback_cf_api_total_eradication)
77
- #
78
- # Re-run idempotency: if env vars are unset, the script reads sshHostname /
79
- # smbHostname from the existing tunnel.state (if present) so a re-run
80
- # without env vars doesn't silently drop the SSH/SMB ingress. tunnel.state
81
- # is rewritten with the resolved hostnames at the end of Step 5b.
82
- # --------------------------------------------------------------------------
83
-
84
- SSH_HOSTNAME="${SSH_HOSTNAME:-}"
85
- SMB_HOSTNAME="${SMB_HOSTNAME:-}"
86
- OPERATOR_EMAIL="${OPERATOR_EMAIL:-}"
87
- SETUP_TUNNEL_DRY_RUN="${SETUP_TUNNEL_DRY_RUN:-0}"
88
-
89
- phase_line setup-tunnel step=start brand="${BRAND}" port="${PORT}" hostnames="${HOSTNAMES[*]}" \
90
- ssh_hostname="${SSH_HOSTNAME:-none}" smb_hostname="${SMB_HOSTNAME:-none}" \
91
- dry_run="${SETUP_TUNNEL_DRY_RUN}"
92
-
93
- # --------------------------------------------------------------------------
94
- # Step 0: Set brand context (paths + dirs). Corresponds to runbook Step 0.
95
- # --------------------------------------------------------------------------
96
-
97
- CFG_DIR="${HOME}/.${BRAND}/cloudflared"
98
- mkdir -p "${CFG_DIR}"
99
-
100
- # brand.json path resolution. Step 5b reads `.operatorEmail` from this file
101
- # when OPERATOR_EMAIL is not in env; absent file is a tolerated no-op.
102
- SETUP_TUNNEL_BRAND_JSON=""
103
- if [ -n "${MAXY_PLATFORM_ROOT:-}" ] && [ -f "${MAXY_PLATFORM_ROOT}/config/brand.json" ]; then
104
- SETUP_TUNNEL_BRAND_JSON="${MAXY_PLATFORM_ROOT}/config/brand.json"
105
- fi
106
-
107
- # --------------------------------------------------------------------------
108
- # Step 1: OAuth login. Corresponds to runbook Step 1.
109
- #
110
- # Step 1 is a state machine over two observable variables — brand-scoped
111
- # cert path and default cert path:
112
- #
113
- # ${CFG_DIR}/cert.pem present → Step 1 already done; skip.
114
- # ${CFG_DIR}/cert.pem missing AND
115
- # ~/.cloudflared/cert.pem present → pre-flight: a prior run
116
- # reached the OAuth callback
117
- # but failed before the mv;
118
- # promote and skip cloudflared.
119
- # both missing → spawn cloudflared, surface
120
- # the OAuth URL on stdout for
121
- # the admin UI to render as a
122
- # clickable link, poll for
123
- # cert.pem, mv.
124
- #
125
- # Control flow when both certs are missing:
126
- # 1. Spawn cloudflared; its stdout+stderr stream into the PTY via stderr
127
- # prefixed with `[script:setup-tunnel:cloudflared]`.
128
- # 2. Extract the authorize URL with a tolerant regex as it streams.
129
- # 3. Print the URL on stdout as `OAUTH_URL: <url>` — the native Claude
130
- # Code PTY linkifies it. The script does not spawn a browser; the
131
- # operator's local browser is the canonical surface. cloudflared's
132
- # OAuth callback writes ~/.cloudflared/cert.pem regardless of which
133
- # browser completed the Authorize click (it's a server-to-server poll
134
- # against login.cloudflareaccess.org).
135
- # 4. Wait for ~/.cloudflared/cert.pem to land (180 s budget — operator
136
- # must click the link and Authorize on Cloudflare).
137
- # 5. Move cert.pem into the brand-scoped path.
138
- #
139
- # Every failure branch exits 1 loudly naming the cause.
140
- # --------------------------------------------------------------------------
141
-
142
- # Pre-flight cert-promotion. When ${CFG_DIR}/cert.pem is missing
143
- # but the OAuth-default ~/.cloudflared/cert.pem is present, a prior run
144
- # reached the cloudflared callback but did not survive to the brand-scoped
145
- # mv at the end of Step 1. The cert is bound to the account already; the
146
- # only remediation is the move. Doing it before re-spawning cloudflared is
147
- # what stops Step 1 from looping forever on a dashboard that has moved past
148
- # a button-bearing state.
149
- #
150
- # The mv exit code is checked: if it fails (EACCES, ENOSPC, weird FS state),
151
- # loud-fail with reason=cert-promote-failed instead of pretending Step 1
152
- # succeeded — the next steps would die opaquely on the missing brand cert.
153
- # stderr is captured into the phase_line so the operator sees the actual
154
- # failure cause (mv exit-code 1 alone cannot disambiguate EACCES from ENOSPC).
155
- if [ ! -f "${CFG_DIR}/cert.pem" ] && [ -f "${HOME}/.cloudflared/cert.pem" ]; then
156
- MV_ERR="$(mktemp -t maxy-cert-promote-err.XXXXXX)"
157
- if mv "${HOME}/.cloudflared/cert.pem" "${CFG_DIR}/cert.pem" 2>"${MV_ERR}"; then
158
- rm -f "${MV_ERR}"
159
- phase_line setup-tunnel step=oauth-login result=ok \
160
- reason=cert-promoted-from-default-path waited="0s"
161
- else
162
- MV_RC=$?
163
- MV_STDERR="$(tr '\n' ' ' < "${MV_ERR}" 2>/dev/null | head -c 200 || echo unavailable)"
164
- rm -f "${MV_ERR}"
165
- phase_line setup-tunnel step=oauth-login result=error \
166
- reason=cert-promote-failed mv_rc="${MV_RC}" stderr="${MV_STDERR}" \
167
- from="${HOME}/.cloudflared/cert.pem" to="${CFG_DIR}/cert.pem"
168
- echo "ERROR: failed to promote ~/.cloudflared/cert.pem to ${CFG_DIR}/cert.pem (mv exit=${MV_RC})." >&2
169
- echo " mv stderr: ${MV_STDERR}" >&2
170
- echo " Step 1 cannot proceed without the cert in the brand-scoped path." >&2
171
- exit 1
172
- fi
173
- fi
174
-
175
- if [ ! -f "${CFG_DIR}/cert.pem" ]; then
176
- phase_line setup-tunnel step=oauth-login cert_path="${CFG_DIR}/cert.pem"
177
-
178
- URL_FILE="$(mktemp -t maxy-setup-tunnel-url.XXXXXX)"
179
- LAST_LINE_FILE="$(mktemp -t maxy-setup-tunnel-last.XXXXXX)"
180
- : > "${URL_FILE}"
181
- : > "${LAST_LINE_FILE}"
182
- # Track the cloudflared pipeline PID so the EXIT trap can kill it on any
183
- # failure path — including ones that `exit 1` without an explicit kill.
184
- # Missing this trap leaks a cloudflared subshell waiting for the OAuth
185
- # callback forever; subsequent setup-tunnel runs see a stale cert.pem
186
- # landing asynchronously and race against the new URL-extraction pass.
187
- CF_PIPELINE_PID=""
188
- cleanup_oauth() {
189
- [ -n "${CF_PIPELINE_PID}" ] && kill "${CF_PIPELINE_PID}" 2>/dev/null || true
190
- rm -f "${URL_FILE}" "${LAST_LINE_FILE}"
191
- }
192
- trap cleanup_oauth EXIT
193
-
194
- # cloudflared is line-buffered (stdbuf); each line is echoed verbatim into
195
- # the PTY (stderr) while the OAuth URL is also extracted as it streams.
196
- # The subshell holds the whole pipeline so PIPESTATUS[0] (cloudflared's
197
- # exit code) is reachable later.
198
- (
199
- stdbuf -oL -eL cloudflared \
200
- --origincert "${CFG_DIR}/cert.pem" tunnel login 2>&1 |
201
- while IFS= read -r line; do
202
- printf '[script:setup-tunnel:cloudflared] %s\n' "${line}" >&2
203
- printf '%s\n' "${line}" > "${LAST_LINE_FILE}"
204
- if [ ! -s "${URL_FILE}" ]; then
205
- url="$(printf '%s' "${line}" | grep -oE 'https://dash\.cloudflare\.com/argotunnel\?[^ ]+' | head -1 || true)"
206
- if [ -n "${url}" ]; then
207
- printf '%s' "${url}" > "${URL_FILE}"
208
- fi
209
- fi
210
- done
211
- ) &
212
- CF_PIPELINE_PID=$!
213
-
214
- # Wait up to ~15s for the URL to surface in cloudflared's output.
215
- URL_WAIT=0
216
- while [ ! -s "${URL_FILE}" ] && [ "${URL_WAIT}" -lt 30 ]; do
217
- if ! kill -0 "${CF_PIPELINE_PID}" 2>/dev/null; then
218
- phase_line setup-tunnel step=oauth-login result=error \
219
- reason=cloudflared-exited-before-url \
220
- last_line="$(cat "${LAST_LINE_FILE}" 2>/dev/null || echo none)"
221
- echo "ERROR: cloudflared exited before printing the authorize URL." >&2
222
- exit 1
223
- fi
224
- sleep 0.5
225
- URL_WAIT=$((URL_WAIT + 1))
226
- done
227
-
228
- if [ ! -s "${URL_FILE}" ]; then
229
- kill "${CF_PIPELINE_PID}" 2>/dev/null || true
230
- phase_line setup-tunnel step=oauth-login result=error \
231
- reason=url-not-extracted waited=15s \
232
- last_line="$(cat "${LAST_LINE_FILE}" 2>/dev/null || echo none)"
233
- echo "ERROR: cloudflared ran for ~15s without emitting a dash.cloudflare.com/argotunnel URL." >&2
234
- echo " cloudflared output-format may have changed. Check the stream log tail for the raw lines." >&2
235
- exit 1
236
- fi
237
-
238
- AUTH_URL="$(cat "${URL_FILE}")"
239
- phase_line setup-tunnel step=oauth-url-extracted url_extracted=1
240
-
241
- # Emit the URL on stdout in a shape the PTY linkifies natively.
242
- # The native Claude Code terminal renderer turns bare URLs into clicks so
243
- # the operator authorizes Cloudflare in the same browser they are using
244
- # for the admin chat. The script does not spawn a browser of its own.
245
- printf 'OAUTH_URL: %s\n' "${AUTH_URL}"
246
-
247
- # Wait for cert.pem to land — cloudflared writes to ~/.cloudflared/cert.pem
248
- # regardless of --origincert, so watch the canonical location. The wait is
249
- # human-paced: the operator must click the link and Authorize on Cloudflare.
250
- # 180 s default budget; SETUP_TUNNEL_LOGIN_TIMEOUT overrides for testing.
251
- # The 2-second heartbeat inside the loop is the observability contract —
252
- # no form-spawned script is allowed a silent poll of more than ~2 s.
253
- LOGIN_TIMEOUT="${SETUP_TUNNEL_LOGIN_TIMEOUT:-180}"
254
- LOGIN_WAIT=0
255
- while [ ! -f "${HOME}/.cloudflared/cert.pem" ]; do
256
- if ! kill -0 "${CF_PIPELINE_PID}" 2>/dev/null; then
257
- # Pipeline exited — one more cert.pem probe in case it landed right before exit.
258
- if [ -f "${HOME}/.cloudflared/cert.pem" ]; then break; fi
259
- phase_line setup-tunnel step=oauth-login result=error \
260
- reason=cloudflared-exited-no-cert \
261
- last_line="$(cat "${LAST_LINE_FILE}" 2>/dev/null || echo none)"
262
- echo "ERROR: cloudflared exited before cert.pem landed." >&2
263
- exit 1
264
- fi
265
- if [ "${LOGIN_WAIT}" -ge "${LOGIN_TIMEOUT}" ]; then
266
- kill "${CF_PIPELINE_PID}" 2>/dev/null || true
267
- phase_line setup-tunnel step=oauth-login result=error \
268
- reason=timeout-waiting-cert waited="${LOGIN_WAIT}s" \
269
- last_line="$(cat "${LAST_LINE_FILE}" 2>/dev/null || echo none)"
270
- echo "ERROR: Timed out after ${LOGIN_WAIT}s waiting for cert.pem to land." >&2
271
- exit 1
272
- fi
273
- # Heartbeat every 2 s. t=0 is the oauth-url-extracted phase line above;
274
- # the first heartbeat fires at t=2. Without this line the tailer sees
275
- # silence for the full 1-20 s round-trip — the exact state the heartbeat
276
- # contract forbids.
277
- if [ "${LOGIN_WAIT}" -gt 0 ] && [ $((LOGIN_WAIT % 2)) -eq 0 ]; then
278
- phase_line setup-tunnel step=oauth-login result=awaiting-cert \
279
- elapsed="${LOGIN_WAIT}s" timeout="${LOGIN_TIMEOUT}s"
280
- fi
281
- sleep 1
282
- LOGIN_WAIT=$((LOGIN_WAIT + 1))
283
- done
284
-
285
- mv "${HOME}/.cloudflared/cert.pem" "${CFG_DIR}/cert.pem"
286
- phase_line setup-tunnel step=oauth-login result=ok \
287
- path="${CFG_DIR}/cert.pem" waited="${LOGIN_WAIT}s"
288
- fi
289
-
290
- # --------------------------------------------------------------------------
291
- # Step 2+3: Resolve the tunnel identity from operator input.
292
- #
293
- # The local hostname has no authority over which tunnel the operator's
294
- # logged-in Cloudflare account holds. Deriving TUNNEL_NAME locally (e.g.
295
- # "${BRAND}-$(hostname -s)") and reusing-or-creating it produces a new
296
- # tunnel when the device is renamed while existing CNAMEs continue to
297
- # point at the old one.
298
- #
299
- # Contract: the operator's logged-in Cloudflare account is the source of
300
- # truth. The agent enumerates existing tunnels via `cloudflared tunnel list
301
- # --output json` (presented in chat as a numbered list) and then invokes
302
- # this script with exactly one of:
303
- # TUNNEL_ID — operator selected an existing tunnel from the list
304
- # TUNNEL_NAME — operator typed a name to create a new tunnel
305
- # Setting both, or neither, is a misuse.
306
- # --------------------------------------------------------------------------
307
-
308
- if [ -n "${TUNNEL_ID:-}" ] && [ -n "${TUNNEL_NAME:-}" ]; then
309
- phase_line setup-tunnel step=tunnel-resolve result=error reason=both-set
310
- echo "ERROR: TUNNEL_ID and TUNNEL_NAME are mutually exclusive — pass exactly one." >&2
311
- exit 1
312
- fi
313
- if [ -z "${TUNNEL_ID:-}" ] && [ -z "${TUNNEL_NAME:-}" ]; then
314
- phase_line setup-tunnel step=tunnel-resolve result=error reason=neither-set
315
- echo "ERROR: TUNNEL_ID (selected) or TUNNEL_NAME (explicit-create) is required." >&2
316
- echo " The agent enumerates existing tunnels via" >&2
317
- echo " 'cloudflared tunnel list --output json' and passes one of" >&2
318
- echo " these env vars on invocation. Do not invoke this script" >&2
319
- echo " directly without one of them set." >&2
320
- exit 1
321
- fi
322
-
323
- if [ -n "${TUNNEL_ID:-}" ]; then
324
- # Operator-selected branch. Resolve the name back from `tunnel list` for
325
- # the log line + tunnel.state file. A missing row here means the operator
326
- # selected a tunnel that has since been deleted from another surface
327
- # (rare but possible) — fail loudly so the operator sees the cause
328
- # rather than getting a silent fallback to "create new".
329
- TUNNEL_NAME="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
330
- | jq -r --arg I "${TUNNEL_ID}" '.[]? | select(.id == $I) | .name' | head -1)"
331
- if [ -z "${TUNNEL_NAME}" ] || [ "${TUNNEL_NAME}" = "null" ]; then
332
- phase_line setup-tunnel step=tunnel-resolve result=error \
333
- reason=selected-tunnel-not-found tunnel_id="${TUNNEL_ID}"
334
- echo "ERROR: TUNNEL_ID ${TUNNEL_ID} is not on the logged-in Cloudflare account." >&2
335
- echo " It may have been deleted from the dashboard since the form loaded." >&2
336
- echo " Re-render the form to refresh the tunnel list." >&2
337
- exit 1
338
- fi
339
- TUNNEL_SOURCE="operator-selected"
340
- else
341
- # Operator-create branch. Refuse to silently reuse an existing tunnel of
342
- # the same name — silent reuse hides which tunnel CNAMEs actually point
343
- # at. The operator should have picked it from the list.
344
- EXISTING_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
345
- | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
346
- if [ -n "${EXISTING_ID}" ] && [ "${EXISTING_ID}" != "null" ]; then
347
- phase_line setup-tunnel step=tunnel-resolve result=error \
348
- reason=name-already-exists tunnel_name="${TUNNEL_NAME}" tunnel_id="${EXISTING_ID}"
349
- echo "ERROR: a tunnel named ${TUNNEL_NAME} already exists (id=${EXISTING_ID})." >&2
350
- echo " Re-render the form and select it from the list, or pick a different name." >&2
351
- exit 1
352
- fi
353
- cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel create "${TUNNEL_NAME}"
354
- TUNNEL_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json \
355
- | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
356
- if [ -z "${TUNNEL_ID}" ] || [ "${TUNNEL_ID}" = "null" ]; then
357
- phase_line setup-tunnel step=tunnel-resolve result=error \
358
- reason=create-then-uuid-missing tunnel_name="${TUNNEL_NAME}"
359
- echo "ERROR: created tunnel ${TUNNEL_NAME} but its UUID is missing from tunnel list." >&2
360
- exit 1
361
- fi
362
- TUNNEL_SOURCE="operator-created"
363
- fi
364
- phase_line setup-tunnel step=tunnel-resolve source="${TUNNEL_SOURCE}" \
365
- tunnel_id="${TUNNEL_ID}" tunnel_name="${TUNNEL_NAME}"
366
- echo "tunnel: ${TUNNEL_NAME} (${TUNNEL_ID}) [${TUNNEL_SOURCE}]"
367
-
368
- # --------------------------------------------------------------------------
369
- # Step 3b: Zone pre-flight. Before routing DNS, verify every non-apex
370
- # hostname's registrable parent (last two labels, e.g. rogerblack.maxy.bot
371
- # → maxy.bot) has NS records pointing at Cloudflare. If any hostname's
372
- # parent zone is not on Cloudflare, refuse the whole run before calling
373
- # `cloudflared tunnel route dns`.
374
- #
375
- # DESIGN NOTE — what this catches and what it does NOT catch:
376
- # CATCHES: parent zone does not exist, or its NS records do not point
377
- # at Cloudflare's nameservers. Pre-529 the shell relied on a post-
378
- # flight sed of cloudflared's stdout for this defence
379
- # deletes that parser because it rejects the idempotent no-op output
380
- # shape (session 25674fe3) and replaces it with this inline NS probe.
381
- # Same primitive the MCP path uses in
382
- # cloudflared.ts::checkZoneParentOnCloudflare.
383
- # DOES NOT CATCH: the zone is on Cloudflare but on a DIFFERENT account
384
- # than the one cert.pem is bound to. A true account-zone-list check
385
- # requires either a cloudflared CLI zone-list subcommand (does not
386
- # exist as of 2026-04) or persisting the bound account's zones at
387
- # tunnel-login time (deferred — separate task). The wrong-account
388
- # case is detected post-hoc by tunnel-status's hostname probe, not
389
- # here. This is an explicitly accepted gap per the scope.
390
- #
391
- # Probe uses 1.1.1.1 directly to bypass the device's local resolver
392
- # (matching Resolver.setServers in the MCP path) — avoids cache /
393
- # split-horizon issues on the Pi.
394
- # --------------------------------------------------------------------------
395
-
396
- is_apex() {
397
- local h="$1"
398
- # Apex heuristic: exactly one dot (e.g. maxy.chat). Breaks for
399
- # multi-label public suffixes like .co.uk; refine if needed.
400
- [ "$(echo -n "$h" | tr -cd '.' | wc -c)" = "1" ]
401
- }
402
-
403
- registrable_parent() {
404
- local h="$1"
405
- local labels n
406
- IFS='.' read -ra labels <<< "${h}"
407
- n=${#labels[@]}
408
- if [ "${n}" -le 2 ]; then
409
- printf '%s' "${h}"
410
- else
411
- printf '%s.%s' "${labels[$((n-2))]}" "${labels[$((n-1))]}"
412
- fi
413
- }
414
-
415
- if ! command -v dig >/dev/null 2>&1; then
416
- phase_line setup-tunnel step=zone-preflight result=error \
417
- reason=dig-missing
418
- echo "ERROR: dig is not in PATH — required for the zone pre-flight check." >&2
419
- echo " Re-run the Maxy installer to reprovision DNS tooling (bind9-dnsutils)." >&2
420
- exit 1
421
- fi
422
-
423
- ZONES_SEEN=""
424
- MISSING_PARENT=""
425
- for H in "${HOSTNAMES[@]}"; do
426
- if is_apex "$H"; then continue; fi
427
- ZONE="$(registrable_parent "$H")"
428
- NS_OUT="$(dig +short +time=3 +tries=1 NS "${ZONE}" @1.1.1.1 2>/dev/null || true)"
429
- if printf '%s' "${NS_OUT}" | grep -qiE '\.ns\.cloudflare\.com\.?$'; then
430
- case ",${ZONES_SEEN}," in
431
- *",${ZONE},"*) ;;
432
- *) ZONES_SEEN="${ZONES_SEEN:+${ZONES_SEEN},}${ZONE}" ;;
433
- esac
434
- else
435
- MISSING_PARENT="${H}"
436
- break
437
- fi
438
- done
439
-
440
- if [ -n "${MISSING_PARENT}" ]; then
441
- MISSING_ZONE="$(registrable_parent "${MISSING_PARENT}")"
442
- phase_line setup-tunnel step=zone-preflight result=error \
443
- missing_parent_for="${MISSING_PARENT}" \
444
- zones_on_account="${ZONES_SEEN}"
445
- echo "" >&2
446
- echo "ERROR: cannot route ${MISSING_PARENT} — its parent zone ${MISSING_ZONE}" >&2
447
- echo " is not on Cloudflare (NS records do not point at *.ns.cloudflare.com)." >&2
448
- echo " Zones confirmed on Cloudflare so far: ${ZONES_SEEN:-none}" >&2
449
- echo "" >&2
450
- echo " Fix: sign into the Cloudflare account that owns ${MISSING_ZONE}" >&2
451
- echo " 1. ~/reset-tunnel.sh # clear cert.pem and tunnel state" >&2
452
- echo " 2. ~/setup-tunnel.sh ... # re-run while signed into the correct account" >&2
453
- exit 1
454
- fi
455
-
456
- phase_line setup-tunnel step=zone-preflight result=ok \
457
- zones_on_account="${ZONES_SEEN}"
458
-
459
- # --------------------------------------------------------------------------
460
- # Step 4: Route DNS. Apex hostnames (exactly two DNS labels) cannot be
461
- # routed via `cloudflared tunnel route dns` — it misroutes them into
462
- # another zone on the account. Skip CLI routing for apex; collect for the
463
- # ACTION REQUIRED summary at the end.
464
- #
465
- # Control flow: cloudflared's exit code is the sole decision
466
- # signal. No stdout parsing. `cloudflared tunnel route dns --overwrite-dns`
467
- # exits 0 on every legitimate outcome (create, overwrite, already-correct
468
- # no-op) and non-zero on every legitimate failure. The pre-flight above
469
- # already refused if the parent zone is not on Cloudflare; the post-flight
470
- # parser the shell historically carried (deleted in 559) rejected the
471
- # idempotent no-op output shape `INF <h> is already configured to
472
- # route...` and caused session 25674fe3 to die after cloudflared exited 0.
473
- # --------------------------------------------------------------------------
474
-
475
- APEX_HOSTNAMES=()
476
- for H in "${HOSTNAMES[@]}"; do
477
- if is_apex "$H"; then
478
- APEX_HOSTNAMES+=("$H")
479
- phase_line setup-tunnel step=route-dns hostname="${H}" result=apex-skip
480
- echo "apex ${H} — skipping CLI DNS routing (manual dashboard step required)"
481
- continue
482
- fi
483
-
484
- phase_line setup-tunnel step=route-dns hostname="${H}" tunnel_id="${TUNNEL_ID}"
485
- ROUTE_LOG="$(mktemp -t maxy-route-dns.XXXXXX)"
486
- # tee_subprocess_capture streams cloudflared's combined stdout+stderr
487
- # into STREAM_LOG_PATH line-by-line with the [script:setup-tunnel:cloudflared]
488
- # tag (live-tailable chat-surface namespace) AND passes the same output through this shell's
489
- # stdout so the `> "${ROUTE_LOG}"` redirection can capture it for the
490
- # failure-path phase_line. Exit code is cloudflared's PIPESTATUS[0].
491
- if tee_subprocess_capture setup-tunnel:cloudflared -- \
492
- cloudflared --origincert "${CFG_DIR}/cert.pem" \
493
- tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${H}" \
494
- > "${ROUTE_LOG}"; then
495
- phase_line setup-tunnel step=route-dns hostname="${H}" result=ok
496
- else
497
- ROUTE_RC=$?
498
- STDERR_BOUNDED="$(tr '\n' ' ' < "${ROUTE_LOG}" | head -c 400)"
499
- phase_line setup-tunnel step=route-dns hostname="${H}" result=error \
500
- exit="${ROUTE_RC}" stderr="${STDERR_BOUNDED}"
501
- echo "ERROR: cloudflared tunnel route dns failed for ${H} (exit=${ROUTE_RC})" >&2
502
- echo " stderr: ${STDERR_BOUNDED}" >&2
503
- rm -f "${ROUTE_LOG}"
504
- exit 1
505
- fi
506
- rm -f "${ROUTE_LOG}"
507
- done
508
-
509
- # --------------------------------------------------------------------------
510
- # Rehydrate SSH/SMB hostnames from tunnel.state when env unset.
511
- #
512
- # A re-run without SSH_HOSTNAME / SMB_HOSTNAME should keep the previously
513
- # configured ingress rather than silently dropping them. The Node helper
514
- # reads tunnel.state (if present) and prints a JSON {sshHostname, smbHostname}.
515
- # --------------------------------------------------------------------------
516
-
517
- TUNNEL_INGRESS_TS="${SCRIPT_DIR}/tunnel-ingress.ts"
518
- if [ ! -f "${TUNNEL_INGRESS_TS}" ]; then
519
- phase_line setup-tunnel step=ingress-renderer-resolve result=error \
520
- reason=helper-missing path="${TUNNEL_INGRESS_TS}"
521
- echo "ERROR: tunnel-ingress.ts is missing at ${TUNNEL_INGRESS_TS}" >&2
522
- exit 1
523
- fi
524
-
525
- if [ -z "${SSH_HOSTNAME}${SMB_HOSTNAME}" ] && [ -f "${CFG_DIR}/tunnel.state" ]; then
526
- PERSISTED_JSON="$(node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" read-state "${CFG_DIR}/tunnel.state" 2>/dev/null || echo '{}')"
527
- PERSISTED_SSH="$(printf '%s' "${PERSISTED_JSON}" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d);process.stdout.write(o.sshHostname||"")}catch{}})')"
528
- PERSISTED_SMB="$(printf '%s' "${PERSISTED_JSON}" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d);process.stdout.write(o.smbHostname||"")}catch{}})')"
529
- if [ -n "${PERSISTED_SSH}" ]; then
530
- SSH_HOSTNAME="${PERSISTED_SSH}"
531
- phase_line setup-tunnel step=ssh-hostname-rehydrated hostname="${SSH_HOSTNAME}"
532
- fi
533
- if [ -n "${PERSISTED_SMB}" ]; then
534
- SMB_HOSTNAME="${PERSISTED_SMB}"
535
- phase_line setup-tunnel step=smb-hostname-rehydrated hostname="${SMB_HOSTNAME}"
536
- fi
537
- fi
538
-
539
- # --------------------------------------------------------------------------
540
- # Samba presence probe (gates SMB ingress stanza).
541
- #
542
- #, the brand's Samba stanza `[<brand>]` is written to
543
- # /etc/samba/smb.conf at install time. If the stanza is absent, this Pi
544
- # has no SMB share to expose and the SMB ingress is skipped loudly.
545
- # Probe via grep on the Pi filesystem (NOT brand.json — Samba is a
546
- # post-install side effect, not a declared field).
547
- # --------------------------------------------------------------------------
548
-
549
- if [ -n "${SMB_HOSTNAME}" ]; then
550
- if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" probe-samba "${BRAND}" /etc/samba/smb.conf >/dev/null 2>&1; then
551
- phase_line setup-tunnel step=smb-ingress-skipped reason=samba-not-provisioned \
552
- hostname="${SMB_HOSTNAME}" brand="${BRAND}"
553
- echo "[tunnel-install] smb-ingress-skipped reason=samba-not-provisioned brand=${BRAND}"
554
- echo " Skipping SMB ingress: /etc/samba/smb.conf has no [${BRAND}] stanza."
555
- echo " Provision Samba first (the installer's post-install step does this); then re-run."
556
- SMB_HOSTNAME=""
557
- fi
558
- fi
559
-
560
- # --------------------------------------------------------------------------
561
- # Step 4b: Route DNS for SSH/SMB hostnames (after HTTPS pass).
562
- #
563
- # Two-phase ordering: HTTPS hostnames are routed first (loop above). If a
564
- # subsequent SSH or SMB route DNS fails, emit `*-ingress-deferred` and
565
- # continue WITHOUT exit 1, so the HTTPS ingress remains durable. Apex
566
- # hostnames are not valid for SSH/SMB (the hostnames are subdomains by
567
- # definition); no apex carve-out is needed.
568
- # --------------------------------------------------------------------------
569
-
570
- route_extra_hostname() {
571
- local kind="$1" # "ssh" or "smb"
572
- local hostname="$2"
573
- [ -z "${hostname}" ] && return 0
574
-
575
- if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
576
- phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" result=dry-run-skip
577
- return 0
578
- fi
579
-
580
- phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" tunnel_id="${TUNNEL_ID}"
581
- local ROUTE_LOG
582
- ROUTE_LOG="$(mktemp -t "maxy-${kind}-route-dns.XXXXXX")"
583
- if tee_subprocess_capture "setup-tunnel:cloudflared" -- \
584
- cloudflared --origincert "${CFG_DIR}/cert.pem" \
585
- tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${hostname}" \
586
- > "${ROUTE_LOG}"; then
587
- phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" result=ok
588
- rm -f "${ROUTE_LOG}"
589
- return 0
590
- fi
591
- local ROUTE_RC=$?
592
- local STDERR_BOUNDED
593
- STDERR_BOUNDED="$(tr '\n' ' ' < "${ROUTE_LOG}" | head -c 400)"
594
- phase_line setup-tunnel step="${kind}-ingress-deferred" hostname="${hostname}" \
595
- reason=route-dns-failed exit="${ROUTE_RC}" stderr="${STDERR_BOUNDED}"
596
- echo "WARNING: cloudflared tunnel route dns failed for ${hostname} (exit=${ROUTE_RC})." >&2
597
- echo " HTTPS ingress remains durable; the ${kind} ingress is deferred." >&2
598
- rm -f "${ROUTE_LOG}"
599
- # Clear the hostname so the config.yml render below skips this entry.
600
- if [ "${kind}" = "ssh" ]; then SSH_HOSTNAME=""; else SMB_HOSTNAME=""; fi
601
- return 0
602
- }
603
-
604
- route_extra_hostname ssh "${SSH_HOSTNAME}"
605
- route_extra_hostname smb "${SMB_HOSTNAME}"
606
-
607
- # --------------------------------------------------------------------------
608
- # Step 5: Write config.yml via the unit-tested Node renderer. Every HTTPS
609
- # hostname (apex + subdomain) gets an ingress rule — apex still needs the
610
- # rule for the connector to serve traffic once DNS is manually pointed.
611
- # SSH and SMB rules are inserted between the HTTPS rules and the catch-all
612
- # http_status:404 (renderer enforces ordering).
613
- # --------------------------------------------------------------------------
614
-
615
- INGRESS_SPEC_FILE="$(mktemp -t maxy-ingress-spec.XXXXXX.json)"
616
- {
617
- printf '{'
618
- printf '"tunnelId":"%s",' "${TUNNEL_ID}"
619
- printf '"credentialsPath":"%s",' "${CFG_DIR}/${TUNNEL_ID}.json"
620
- printf '"httpPort":%s,' "${PORT}"
621
- printf '"httpHostnames":['
622
- FIRST=1
623
- for H in "${HOSTNAMES[@]}"; do
624
- if [ ${FIRST} -eq 1 ]; then FIRST=0; else printf ','; fi
625
- printf '"%s"' "${H}"
626
- done
627
- printf '],'
628
- printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
629
- printf '"smbHostname":"%s"' "${SMB_HOSTNAME}"
630
- printf '}'
631
- } > "${INGRESS_SPEC_FILE}"
632
-
633
- if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
634
- echo "--- DRY RUN: rendered config.yml ---"
635
- node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-config "${INGRESS_SPEC_FILE}"
636
- echo "--- DRY RUN: end ---"
637
- phase_line setup-tunnel step=config-yml-write result=dry-run-skip
638
- else
639
- if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-config "${INGRESS_SPEC_FILE}" > "${CFG_DIR}/config.yml"; then
640
- phase_line setup-tunnel step=config-yml-write result=error reason=renderer-failed
641
- echo "ERROR: tunnel-ingress render-config failed" >&2
642
- rm -f "${INGRESS_SPEC_FILE}"
643
- exit 1
644
- fi
645
- echo "wrote ${CFG_DIR}/config.yml"
646
- fi
647
- rm -f "${INGRESS_SPEC_FILE}"
648
-
649
- if [ -n "${SSH_HOSTNAME}" ]; then
650
- phase_line setup-tunnel step=ssh-ingress-added hostname="${SSH_HOSTNAME}" service=ssh://localhost:22
651
- echo "[tunnel-install] ssh-ingress-added hostname=${SSH_HOSTNAME} service=ssh://localhost:22"
652
- fi
653
- if [ -n "${SMB_HOSTNAME}" ]; then
654
- phase_line setup-tunnel step=smb-ingress-added hostname="${SMB_HOSTNAME}" service=tcp://localhost:445
655
- echo "[tunnel-install] smb-ingress-added hostname=${SMB_HOSTNAME} service=tcp://localhost:445"
656
- fi
657
-
658
- # --------------------------------------------------------------------------
659
- # Step 5b: Write tunnel.state via the same renderer. Additive sshHostname /
660
- # smbHostname fields let the next re-run rehydrate them. Required by
661
- # resume-tunnel.sh — without it the brand.service's ExecStartPre exits
662
- # silently without spawning a connector.
663
- # --------------------------------------------------------------------------
664
-
665
- STATE_SPEC_FILE="$(mktemp -t maxy-tunnel-state.XXXXXX.json)"
666
- {
667
- printf '{'
668
- printf '"tunnelId":"%s",' "${TUNNEL_ID}"
669
- printf '"tunnelName":"%s",' "${TUNNEL_NAME}"
670
- printf '"domain":"%s",' "${HOSTNAMES[0]}"
671
- printf '"configPath":"%s",' "${CFG_DIR}/config.yml"
672
- printf '"credentialsPath":"%s",' "${CFG_DIR}/${TUNNEL_ID}.json"
673
- printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
674
- printf '"smbHostname":"%s"' "${SMB_HOSTNAME}"
675
- printf '}'
676
- } > "${STATE_SPEC_FILE}"
677
-
678
- if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
679
- echo "--- DRY RUN: rendered tunnel.state ---"
680
- node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-state "${STATE_SPEC_FILE}"
681
- echo "--- DRY RUN: end ---"
682
- phase_line setup-tunnel step=tunnel-state-write result=dry-run-skip
683
- else
684
- if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-state "${STATE_SPEC_FILE}" > "${CFG_DIR}/tunnel.state"; then
685
- phase_line setup-tunnel step=tunnel-state-write result=error reason=renderer-failed
686
- echo "ERROR: tunnel-ingress render-state failed" >&2
687
- rm -f "${STATE_SPEC_FILE}"
688
- exit 1
689
- fi
690
- echo "wrote ${CFG_DIR}/tunnel.state"
691
- fi
692
- rm -f "${STATE_SPEC_FILE}"
693
-
694
- # --------------------------------------------------------------------------
695
- # Access policy ACTION REQUIRED.
696
- #
697
- # The Cloudflare Access policy that gates these new SSH/SMB hostnames must
698
- # be authored in the dashboard by the operator: CF API/SDK is banned
699
- # (feedback_cf_api_total_eradication) and `cloudflared` CLI has no
700
- # Access-application create subcommand. Print the click-path with the
701
- # resolved hostnames + operator email substituted in. operatorEmail comes
702
- # from OPERATOR_EMAIL env, falling back to brand.json `.operatorEmail`.
703
- # --------------------------------------------------------------------------
704
-
705
- if [ -n "${SSH_HOSTNAME}${SMB_HOSTNAME}" ]; then
706
- RESOLVED_EMAIL="${OPERATOR_EMAIL}"
707
- if [ -z "${RESOLVED_EMAIL}" ] && [ -n "${SETUP_TUNNEL_BRAND_JSON}" ] && command -v jq >/dev/null 2>&1; then
708
- RESOLVED_EMAIL="$(jq -r '.operatorEmail // empty' "${SETUP_TUNNEL_BRAND_JSON}" 2>/dev/null || true)"
709
- fi
710
- if [ -z "${RESOLVED_EMAIL}" ]; then
711
- RESOLVED_EMAIL="<operator email — set OPERATOR_EMAIL or brand.json .operatorEmail>"
712
- phase_line setup-tunnel step=operator-email-unresolved reason=env-and-brand-json-missing
713
- fi
714
-
715
- if [ -n "${SSH_HOSTNAME}" ]; then
716
- phase_line setup-tunnel step=ssh-access-policy-required hostname="${SSH_HOSTNAME}" allow_emails="${RESOLVED_EMAIL}"
717
- echo "[tunnel-install] ssh-access-policy-required hostname=${SSH_HOSTNAME} allow-emails=${RESOLVED_EMAIL}"
718
- fi
719
- if [ -n "${SMB_HOSTNAME}" ]; then
720
- phase_line setup-tunnel step=smb-access-policy-required hostname="${SMB_HOSTNAME}" allow_emails="${RESOLVED_EMAIL}"
721
- echo "[tunnel-install] smb-access-policy-required hostname=${SMB_HOSTNAME} allow-emails=${RESOLVED_EMAIL}"
722
- fi
723
-
724
- AR_INPUT_FILE="$(mktemp -t maxy-access-req.XXXXXX.json)"
725
- {
726
- printf '{'
727
- printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
728
- printf '"smbHostname":"%s",' "${SMB_HOSTNAME}"
729
- printf '"operatorEmail":"%s"' "${RESOLVED_EMAIL}"
730
- printf '}'
731
- } > "${AR_INPUT_FILE}"
732
- node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" action-req "${AR_INPUT_FILE}" || true
733
- rm -f "${AR_INPUT_FILE}"
734
- fi
735
-
736
- # --------------------------------------------------------------------------
737
- # Dry-run short-circuit: exit before onboarding / service restart.
738
- # --------------------------------------------------------------------------
739
- if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
740
- phase_line setup-tunnel step=done dry_run=1
741
- echo ""
742
- echo "DRY RUN complete. No cloudflared mutations made; no service restart."
743
- exit 0
744
- fi
745
-
746
- # --------------------------------------------------------------------------
747
- # Restart the brand's user-space service so resume-tunnel.sh (its
748
- # ExecStartPre) picks up the new tunnel.state + config.yml and spawns the
749
- # connector.
750
- #
751
- # CRITICAL: this script runs inside ${BRAND}.service's cgroup whenever the
752
- # admin agent invokes it via the Bash tool. `systemctl --user restart
753
- # ${BRAND}.service` from inside that cgroup SIGTERMs the whole cgroup —
754
- # the node server, the claude subprocess, the Bash child, and this script
755
- # itself. Dispatching the restart to a transient systemd-run
756
- # unit is the ONLY primitive that creates a new cgroup outside the service
757
- # — setsid/nohup/disown/& all inherit cgroup membership, and
758
- # `systemd-run --scope` runs in the caller's scope.
759
- #
760
- # The transient timer fires $RESTART_DELAY seconds after dispatch; the
761
- # script exits 0 cleanly in microseconds, then the timer restarts the
762
- # service from its own cgroup — semantically identical to an operator
763
- # SSH-invoked `systemctl restart`. Post-restart verification (connector
764
- # up + hostname probe) is out of scope here — the client reconnects and
765
- # the next admin turn can verify via MCP tools.
766
- # --------------------------------------------------------------------------
767
-
768
- if ! systemctl --user list-unit-files "${BRAND}.service" --no-pager --no-legend | grep -q "${BRAND}.service"; then
769
- echo "ERROR: user-space service ${BRAND}.service not installed — cannot bring the tunnel up" >&2
770
- echo " Install the platform first (create-maxy) before running this script." >&2
771
- exit 1
772
- fi
773
-
774
- if ! command -v systemd-run >/dev/null 2>&1; then
775
- phase_line setup-tunnel step=service-restart-dispatched result=error \
776
- reason=systemd-run-missing
777
- echo "ERROR: systemd-run is not in PATH." >&2
778
- echo " The script dispatches the ${BRAND}.service restart to a transient" >&2
779
- echo " systemd user unit so it does not kill its own cgroup." >&2
780
- echo " Install systemd userspace (standard on supported Maxy Pi images)." >&2
781
- exit 1
782
- fi
783
-
784
- RESTART_DELAY=3
785
- TRANSIENT_UNIT="maxy-tunnel-restart-$$-$(date +%s)"
786
- phase_line setup-tunnel step=service-restart-dispatched \
787
- unit="${TRANSIENT_UNIT}" delay="${RESTART_DELAY}s" \
788
- cmd="systemctl --user restart ${BRAND}.service"
789
-
790
- # Dispatch via systemd-run --user --on-active — creates a transient unit
791
- # with its own cgroup that fires the restart after RESTART_DELAY seconds.
792
- # --collect auto-GCs the unit after it terminates. The script exits before
793
- # the timer fires; no race because the exit is microseconds and the timer
794
- # is seconds. Capture stderr so the operator sees the actual systemd-run
795
- # failure reason (e.g. "Failed to connect to bus" when linger is disabled).
796
- SYSTEMD_RUN_ERR="$(mktemp -t maxy-systemd-run-err.XXXXXX)"
797
- if systemd-run --user --unit="${TRANSIENT_UNIT}.service" \
798
- --description="Detached restart of ${BRAND}.service" \
799
- --on-active="${RESTART_DELAY}s" \
800
- --collect \
801
- /bin/systemctl --user restart "${BRAND}.service" 2>"${SYSTEMD_RUN_ERR}"; then
802
- RESTART_RC=0
803
- else
804
- RESTART_RC=$?
805
- fi
806
-
807
- if [ "${RESTART_RC}" -ne 0 ]; then
808
- STDERR_TEXT="$(cat "${SYSTEMD_RUN_ERR}" 2>/dev/null | tr '\n' ' ' | head -c 500 || echo 'unavailable')"
809
- rm -f "${SYSTEMD_RUN_ERR}"
810
- phase_line setup-tunnel step=service-restart-dispatched result=error \
811
- reason=systemd-run-failed exit="${RESTART_RC}" unit="${TRANSIENT_UNIT}" \
812
- stderr="${STDERR_TEXT}"
813
- echo "ERROR: systemd-run failed to dispatch the transient restart (exit=${RESTART_RC})." >&2
814
- echo " systemd-run stderr: ${STDERR_TEXT}" >&2
815
- echo " If stderr mentions 'Failed to connect to bus', the user-scope systemd" >&2
816
- echo " instance isn't running. Fix: 'loginctl enable-linger \$(whoami)' and retry." >&2
817
- echo " The service was NOT restarted. Re-run the script or restart manually:" >&2
818
- echo " systemctl --user restart ${BRAND}.service" >&2
819
- exit 1
820
- fi
821
- rm -f "${SYSTEMD_RUN_ERR}"
822
-
823
- phase_line setup-tunnel step=service-restart-armed exit=0 unit="${TRANSIENT_UNIT}"
824
- echo "${BRAND}.service restart armed via ${TRANSIENT_UNIT} (fires in ${RESTART_DELAY}s)."
825
-
826
- # --------------------------------------------------------------------------
827
- # Apex ACTION REQUIRED summary
828
- # --------------------------------------------------------------------------
829
-
830
- if [ "${#APEX_HOSTNAMES[@]}" -gt 0 ]; then
831
- echo ""
832
- echo "============================================================"
833
- echo "ACTION REQUIRED — manual dashboard step(s) for apex hostnames"
834
- echo "============================================================"
835
- for H in "${APEX_HOSTNAMES[@]}"; do
836
- echo " ${H}"
837
- echo " Cloudflare dashboard → ${H} zone → DNS → Records"
838
- echo " Edit (or Add) CNAME record:"
839
- echo " Name: @ (zone apex)"
840
- echo " Content: ${TUNNEL_ID}.cfargotunnel.com"
841
- echo " Proxy: Proxied (orange cloud)"
842
- echo ""
843
- done
844
- echo "Until each apex record is set, those hostnames will return 1033."
845
- echo "============================================================"
846
- fi
847
-
848
- phase_line setup-tunnel step=done tunnel="${TUNNEL_NAME}" id="${TUNNEL_ID}"
849
- echo ""
850
- echo "Done. tunnel=${TUNNEL_NAME} id=${TUNNEL_ID}"
851
- echo "Service will restart in ~${RESTART_DELAY}s to load the new config."
852
- echo "Verify hostnames with: curl -I https://${HOSTNAMES[0]}"