@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.
- package/dist/index.js +11 -125
- package/dist/snap-chromium.js +1 -2
- package/dist/uninstall.js +10 -9
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +1 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/upgrade/SKILL.md +34 -0
- package/payload/platform/plugins/cloudflare/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +9 -16
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +7 -12
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +3 -3
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +16 -51
- package/payload/platform/plugins/cloudflare/references/reset-guide.md +24 -25
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +29 -149
- package/payload/platform/plugins/docs/references/admin-session.md +1 -1
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/cloudflare.md +20 -29
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
- package/payload/platform/scripts/check-no-task-id-leaks.mjs +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +0 -241
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +0 -98
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +0 -715
- package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +0 -107
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +0 -852
- 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]}"
|