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