@rubytech/create-realagent-code 0.1.23 → 0.1.26

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 (208) hide show
  1. package/dist/index.js +63 -16
  2. package/package.json +1 -1
  3. package/payload/platform/plugins/admin/PLUGIN.md +50 -23
  4. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +47 -0
  5. package/payload/platform/plugins/admin/skills/commitment-followthrough/SKILL.md +60 -0
  6. package/payload/platform/plugins/admin/skills/file-presentation/SKILL.md +67 -0
  7. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +111 -126
  8. package/payload/platform/plugins/admin/skills/session-management/SKILL.md +62 -0
  9. package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +37 -0
  10. package/payload/platform/plugins/cloudflare/references/manual-setup.md +81 -1
  11. package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +241 -0
  12. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +267 -28
  13. package/payload/platform/plugins/cloudflare/scripts/tunnel-ingress.ts +291 -0
  14. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +42 -0
  15. package/payload/platform/plugins/contacts/PLUGIN.md +18 -9
  16. package/payload/platform/plugins/deep-research/.claude-plugin/plugin.json +1 -1
  17. package/payload/platform/plugins/deep-research/PLUGIN.md +7 -1
  18. package/payload/platform/plugins/deep-research/recipes/README.md +36 -0
  19. package/payload/platform/plugins/deep-research/skills/academic-verify/SKILL.md +75 -0
  20. package/payload/platform/plugins/deep-research/skills/book-mirror/SKILL.md +68 -0
  21. package/payload/platform/plugins/deep-research/skills/data-research/SKILL.md +108 -0
  22. package/payload/platform/plugins/deep-research/skills/strategic-reading/SKILL.md +69 -0
  23. package/payload/platform/plugins/docs/references/deployment.md +3 -2
  24. package/payload/platform/plugins/docs/references/platform.md +2 -0
  25. package/payload/platform/plugins/docs/references/troubleshooting.md +12 -0
  26. package/payload/platform/plugins/email/PLUGIN.md +18 -9
  27. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.d.ts +17 -0
  28. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.d.ts.map +1 -0
  29. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.js +185 -0
  30. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.js.map +1 -0
  31. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +1 -1
  32. package/payload/platform/plugins/email/mcp/dist/scripts/email-auto-respond.js +34 -111
  33. package/payload/platform/plugins/email/mcp/dist/scripts/email-auto-respond.js.map +1 -1
  34. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.d.ts +7 -2
  35. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.d.ts.map +1 -1
  36. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.js +7 -2
  37. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.js.map +1 -1
  38. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -0
  39. package/payload/platform/plugins/memory/PLUGIN.md +64 -29
  40. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts +3 -1
  41. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts.map +1 -1
  42. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +105 -4
  43. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
  44. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
  45. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +16 -3
  46. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  47. package/payload/platform/plugins/memory/skills/archive-crawler/SKILL.md +67 -0
  48. package/payload/platform/plugins/memory/skills/concept-synthesis/SKILL.md +80 -0
  49. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +2 -0
  50. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +2 -0
  51. package/payload/platform/plugins/outlook/PLUGIN.md +14 -7
  52. package/payload/platform/plugins/replicate/PLUGIN.md +6 -3
  53. package/payload/platform/plugins/scheduling/PLUGIN.md +19 -8
  54. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts +7 -3
  55. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +7 -3
  57. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  58. package/payload/platform/plugins/scheduling/skills/briefing/SKILL.md +75 -0
  59. package/payload/platform/plugins/scheduling/skills/daily-prep/SKILL.md +61 -0
  60. package/payload/platform/plugins/tasks/PLUGIN.md +28 -14
  61. package/payload/platform/plugins/waitlist/PLUGIN.md +12 -6
  62. package/payload/platform/plugins/whatsapp/PLUGIN.md +25 -13
  63. package/payload/platform/plugins/workflows/PLUGIN.md +16 -8
  64. package/payload/platform/scripts/conversation-id-allowlist.txt +0 -1
  65. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  66. package/payload/platform/services/claude-session-manager/dist/http-server.js +27 -2
  67. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  68. package/payload/platform/services/claude-session-manager/dist/index.js +27 -0
  69. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +36 -0
  71. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +41 -3
  73. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +25 -1
  75. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +54 -3
  77. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +25 -0
  79. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -0
  80. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +149 -0
  81. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -0
  82. package/payload/platform/templates/agents/admin/IDENTITY.md +38 -284
  83. package/payload/platform/templates/agents/admin/SOUL.md +4 -4
  84. package/payload/platform/templates/specialists/agents/content-producer.md +24 -69
  85. package/payload/platform/templates/specialists/agents/database-operator.md +49 -155
  86. package/payload/platform/templates/specialists/agents/personal-assistant.md +27 -177
  87. package/payload/platform/templates/specialists/agents/project-manager.md +29 -96
  88. package/payload/platform/templates/specialists/agents/research-assistant.md +36 -78
  89. package/payload/premium-plugins/real-agency/agents/compliance.md +14 -0
  90. package/payload/premium-plugins/real-agency/agents/negotiator.md +22 -0
  91. package/payload/premium-plugins/real-agency/agents/valuer.md +16 -0
  92. package/payload/premium-plugins/real-agency/plugins/estate-business/.claude-plugin/plugin.json +1 -1
  93. package/payload/premium-plugins/real-agency/plugins/estate-business/PLUGIN.md +44 -13
  94. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/commission-calculator/SKILL.md +40 -0
  95. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/month-end-close/SKILL.md +69 -0
  96. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/payment-batch-stager/SKILL.md +42 -0
  97. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/period-reconciler/SKILL.md +42 -0
  98. package/payload/premium-plugins/real-agency/plugins/estate-sales/.claude-plugin/plugin.json +1 -1
  99. package/payload/premium-plugins/real-agency/plugins/estate-sales/PLUGIN.md +32 -13
  100. package/payload/premium-plugins/real-agency/plugins/estate-sales/skills/chase-progression/SKILL.md +107 -0
  101. package/payload/premium-plugins/real-agency/plugins/estate-sales/skills/risk-scorer/SKILL.md +42 -0
  102. package/payload/premium-plugins/real-agency/plugins/leads/.claude-plugin/plugin.json +1 -1
  103. package/payload/premium-plugins/real-agency/plugins/leads/PLUGIN.md +40 -10
  104. package/payload/premium-plugins/real-agency/plugins/leads/skills/chain-progression-tracker/SKILL.md +51 -0
  105. package/payload/premium-plugins/real-agency/plugins/leads/skills/diary-builder/SKILL.md +38 -0
  106. package/payload/premium-plugins/real-agency/plugins/leads/skills/enquiry-triage/SKILL.md +36 -0
  107. package/payload/premium-plugins/real-agency/plugins/leads/skills/morning-round/SKILL.md +72 -0
  108. package/payload/premium-plugins/real-agency/plugins/listings/.claude-plugin/plugin.json +1 -1
  109. package/payload/premium-plugins/real-agency/plugins/listings/PLUGIN.md +82 -12
  110. package/payload/premium-plugins/real-agency/plugins/listings/skills/comparable-finder/SKILL.md +52 -0
  111. package/payload/premium-plugins/real-agency/plugins/listings/skills/epc-checker/SKILL.md +38 -0
  112. package/payload/premium-plugins/real-agency/plugins/listings/skills/listing-copy-writer/SKILL.md +55 -0
  113. package/payload/premium-plugins/real-agency/plugins/listings/skills/local-market-stats/SKILL.md +33 -0
  114. package/payload/premium-plugins/real-agency/plugins/listings/skills/new-instruction/SKILL.md +78 -0
  115. package/payload/premium-plugins/real-agency/plugins/listings/skills/particulars-builder/SKILL.md +48 -0
  116. package/payload/premium-plugins/real-agency/plugins/listings/skills/portal-launch-scheduler/SKILL.md +49 -0
  117. package/payload/premium-plugins/real-agency/plugins/listings/skills/pricing-scenario-builder/SKILL.md +35 -0
  118. package/payload/premium-plugins/real-agency/plugins/listings/skills/supplier-booker/SKILL.md +39 -0
  119. package/payload/premium-plugins/real-agency/plugins/listings/skills/talk-track-composer/SKILL.md +36 -0
  120. package/payload/premium-plugins/real-agency/plugins/listings/skills/terms-of-business-drafter/SKILL.md +54 -0
  121. package/payload/premium-plugins/real-agency/plugins/listings/skills/valuation-prep/SKILL.md +69 -0
  122. package/payload/premium-plugins/real-agency/plugins/loop/PLUGIN.md +35 -0
  123. package/payload/premium-plugins/real-agency/plugins/loop/skills/compliance-flag-checker/SKILL.md +53 -0
  124. package/payload/premium-plugins/real-agency/plugins/loop/skills/priority-ranker/SKILL.md +40 -0
  125. package/payload/premium-plugins/real-agency/plugins/loop/skills/tone-matched-drafter/SKILL.md +53 -0
  126. package/payload/premium-plugins/real-agency/plugins/loop/skills/variance-narrator/SKILL.md +50 -0
  127. package/payload/premium-plugins/real-agency/plugins/loop/skills/vendor-research/SKILL.md +54 -0
  128. package/payload/server/{chunk-2ZNKHCQB.js → chunk-2MRZBQMH.js} +1 -1
  129. package/payload/server/{chunk-GPUCA2RQ.js → chunk-NL7QLVAD.js} +0 -192
  130. package/payload/server/{chunk-IDKWGLM5.js → chunk-YPZFYTYP.js} +1 -247
  131. package/payload/server/{cloudflare-task-tracker-LYI5BTYI.js → cloudflare-task-tracker-QVOGHKWV.js} +2 -2
  132. package/payload/server/maxy-edge.js +2 -2
  133. package/payload/server/package.json +0 -2
  134. package/payload/server/public/assets/{Checkbox-D1OQD43b.js → Checkbox-YIF0payo.js} +1 -1
  135. package/payload/server/public/assets/{admin-czNBxWor.js → admin-DW8IJcLc.js} +1 -1
  136. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BcwgT80u.js → architectureDiagram-Q4EWVU46-Bz8mlxZZ.js} +1 -1
  137. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BMSyZUQA.js → blockDiagram-DXYQGD6D-DwV8Z8-i.js} +1 -1
  138. package/payload/server/public/assets/{brand-2cku8WFs.css → brand-DqiRNMlu.css} +1 -1
  139. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-DPRGY1jJ.js → c4Diagram-AHTNJAMY-DiUTejMp.js} +1 -1
  140. package/payload/server/public/assets/channel-PtVtoBEL.js +1 -0
  141. package/payload/server/public/assets/{chunk-336JU56O-B7oQ3g1c.js → chunk-336JU56O-4mHZpBXe.js} +2 -2
  142. package/payload/server/public/assets/{chunk-426QAEUC-C1P0yFXw.js → chunk-426QAEUC-Cbv0vrN9.js} +1 -1
  143. package/payload/server/public/assets/{chunk-4TB4RGXK-LI7kOJd0.js → chunk-4TB4RGXK-BvLhId_2.js} +1 -1
  144. package/payload/server/public/assets/{chunk-5FUZZQ4R-CXQRGTQE.js → chunk-5FUZZQ4R-bBafOTkw.js} +1 -1
  145. package/payload/server/public/assets/{chunk-5PVQY5BW-NSyzpXRy.js → chunk-5PVQY5BW-B0NqBKVy.js} +1 -1
  146. package/payload/server/public/assets/{chunk-EDXVE4YY-voNwxbDs.js → chunk-EDXVE4YY-CFd4SqI6.js} +1 -1
  147. package/payload/server/public/assets/{chunk-ENJZ2VHE-CMEMPzYY.js → chunk-ENJZ2VHE-ajf2sb6c.js} +1 -1
  148. package/payload/server/public/assets/{chunk-ICPOFSXX-hEbwu-pe.js → chunk-ICPOFSXX-pWg6bug7.js} +1 -1
  149. package/payload/server/public/assets/{chunk-OYMX7WX6-DxskDrLs.js → chunk-OYMX7WX6-OjEd-17c.js} +1 -1
  150. package/payload/server/public/assets/{chunk-U2HBQHQK-D7TKgUo0.js → chunk-U2HBQHQK-DbEFSPoh.js} +1 -1
  151. package/payload/server/public/assets/{chunk-X2U36JSP-BvPUQEPm.js → chunk-X2U36JSP-COdNwrBb.js} +1 -1
  152. package/payload/server/public/assets/{chunk-YZCP3GAM-BY-RWQUW.js → chunk-YZCP3GAM-CHMWuY9B.js} +1 -1
  153. package/payload/server/public/assets/{chunk-ZZ45TVLE-DZvOYDY6.js → chunk-ZZ45TVLE-B-uDLQOB.js} +1 -1
  154. package/payload/server/public/assets/classDiagram-6PBFFD2Q-RVH_SEhY.js +1 -0
  155. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Cm3rAb93.js +1 -0
  156. package/payload/server/public/assets/clone-BjY0Wzht.js +1 -0
  157. package/payload/server/public/assets/{dagre-KV5264BT-Cnj0mUZl.js → dagre-KV5264BT-CMEzmhIL.js} +1 -1
  158. package/payload/server/public/assets/{dagre-Bt-fpckL.js → dagre-bhIG_KnW.js} +1 -1
  159. package/payload/server/public/assets/data-K_kS__sL.js +1 -0
  160. package/payload/server/public/assets/{device-url-actions-Bjz3Xzbm.js → device-url-actions-AcOyLSeF.js} +1 -1
  161. package/payload/server/public/assets/{diagram-5BDNPKRD-DjLzvOlx.js → diagram-5BDNPKRD-6RIoQhIL.js} +1 -1
  162. package/payload/server/public/assets/{diagram-G4DWMVQ6-DTfuRd-T.js → diagram-G4DWMVQ6-BSp36TVv.js} +1 -1
  163. package/payload/server/public/assets/{diagram-MMDJMWI5-BaL2mCnx.js → diagram-MMDJMWI5-D54fo52D.js} +1 -1
  164. package/payload/server/public/assets/{diagram-TYMM5635-C5InWY5R.js → diagram-TYMM5635-CWL8z-Pq.js} +1 -1
  165. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DO7BXTpn.js → erDiagram-SMLLAGMA-AnnHBo3z.js} +1 -1
  166. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-DDdAKfLf.js → flowDiagram-DWJPFMVM-laWmBl5o.js} +1 -1
  167. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-arJD8Utm.js → ganttDiagram-T4ZO3ILL-B94ko8ie.js} +1 -1
  168. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-C55GH-OS.js → gitGraphDiagram-UUTBAWPF-DxzL1fxZ.js} +1 -1
  169. package/payload/server/public/assets/graph-DeEigyO_.js +1 -0
  170. package/payload/server/public/assets/graph-labels-C7I5QvNv.js +1 -0
  171. package/payload/server/public/assets/{graphlib-DL9PM7Ex.js → graphlib-CY-zIElM.js} +1 -1
  172. package/payload/server/public/assets/{infoDiagram-42DDH7IO-BMSGqUbG.js → infoDiagram-42DDH7IO-BMTajIIr.js} +1 -1
  173. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dw6BZ6BG.js → ishikawaDiagram-UXIWVN3A-B_QauE5O.js} +1 -1
  174. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DrywUGXw.js → journeyDiagram-VCZTEJTY-DmlqSIih.js} +1 -1
  175. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DuwtVBBc.js → kanban-definition-6JOO6SKY-ZGDQT7xB.js} +1 -1
  176. package/payload/server/public/assets/{line-JAksyKHj.js → line-D13opgep.js} +1 -1
  177. package/payload/server/public/assets/{mermaid-parser.core-BMq-ApBW.js → mermaid-parser.core-C650Sual.js} +1 -1
  178. package/payload/server/public/assets/{mermaid.core-tH4oX0Kh.js → mermaid.core-BqnQoXTp.js} +3 -3
  179. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-D1OiiJga.js → mindmap-definition-QFDTVHPH-BS_8y-tY.js} +1 -1
  180. package/payload/server/public/assets/{page-BZpoS7iR.js → page-B_rpjIRr.js} +1 -1
  181. package/payload/server/public/assets/{page-CkvBvezS.js → page-qSH972X0.js} +1 -1
  182. package/payload/server/public/assets/{pieDiagram-DEJITSTG-Ckwm69PW.js → pieDiagram-DEJITSTG-B5OmNvBO.js} +1 -1
  183. package/payload/server/public/assets/{public-C-dTMgXu.js → public-DDsYgotk.js} +3 -3
  184. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-COw3yZ1j.js → quadrantDiagram-34T5L4WZ-DTYITdNo.js} +1 -1
  185. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DqGzM4K-.js → requirementDiagram-MS252O5E-CRZWxH06.js} +1 -1
  186. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D-l1c_Pl.js → sankeyDiagram-XADWPNL6-DazRENhe.js} +1 -1
  187. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BeIi0DtJ.js → sequenceDiagram-FGHM5R23-BcHTxmPy.js} +1 -1
  188. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-C-jgegLk.js → stateDiagram-FHFEXIEX-DYU7nbqg.js} +1 -1
  189. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BgljVtlp.js +1 -0
  190. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BGFKkYmi.js → timeline-definition-GMOUNBTQ-BKGmqkST.js} +1 -1
  191. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-5NuIhJLS.js → vennDiagram-DHZGUBPP-BXvLPmX7.js} +1 -1
  192. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-Be9ytVut.js → wardleyDiagram-NUSXRM2D-BCclUa3Z.js} +1 -1
  193. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DCyHg41R.js → xychartDiagram-5P7HB3ND-C-Xp-Eoc.js} +1 -1
  194. package/payload/server/public/data.html +5 -5
  195. package/payload/server/public/graph.html +6 -6
  196. package/payload/server/public/index.html +8 -8
  197. package/payload/server/public/public.html +5 -5
  198. package/payload/server/server.js +1152 -2564
  199. package/payload/platform/scripts/check-sdk-oauth.mjs +0 -185
  200. package/payload/server/public/assets/channel-fxEghWew.js +0 -1
  201. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BsWzGW0N.js +0 -1
  202. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BGVa3h90.js +0 -1
  203. package/payload/server/public/assets/clone-Khvocke2.js +0 -1
  204. package/payload/server/public/assets/data-DBd-Buhp.js +0 -1
  205. package/payload/server/public/assets/graph-DUtVdnZ6.js +0 -1
  206. package/payload/server/public/assets/graph-labels-Dxfue-fP.js +0 -1
  207. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BaMs8Znv.js +0 -1
  208. /package/payload/server/public/assets/{brand-CSQuxS9w.js → brand-Bm671owU.js} +0 -0
@@ -279,6 +279,78 @@ cat "${CFG_DIR}/config.yml"
279
279
  cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate
280
280
  ```
281
281
 
282
+ ### Off-LAN SSH and SMB ingress (Task 009)
283
+
284
+ The same tunnel can also carry SSH and SMB to the Pi so an off-LAN
285
+ operator can SSH in or mount the brand share without VPN or port
286
+ forwarding. Insert SSH/SMB ingress rules between the HTTP rules and the
287
+ catch-all `http_status:404`:
288
+
289
+ ```
290
+ ingress:
291
+ - hostname: admin.maxy.bot
292
+ service: http://localhost:${PORT}
293
+ - hostname: public.maxy.bot
294
+ service: http://localhost:${PORT}
295
+ - hostname: ssh.maxy.bot
296
+ service: ssh://localhost:22
297
+ - hostname: smb.maxy.bot
298
+ service: tcp://localhost:445
299
+ - service: http_status:404
300
+ ```
301
+
302
+ Then route the new hostnames (Step 4 equivalent):
303
+
304
+ ```
305
+ cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" ssh.maxy.bot
306
+ cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" smb.maxy.bot
307
+ ```
308
+
309
+ **Order matters in the script path:** `setup-tunnel.sh` routes HTTPS
310
+ hostnames first and rewrites `config.yml` only after the HTTPS pass is
311
+ durable, so a failure on `ssh` or `smb` route DNS does not nuke the
312
+ HTTPS ingress; the failing hostname is logged as
313
+ `[tunnel-install] {ssh,smb}-ingress-deferred` and config.yml is rendered
314
+ without it.
315
+
316
+ **SMB depends on Task 034.** Before adding the SMB ingress, confirm the
317
+ brand stanza exists in `/etc/samba/smb.conf`:
318
+
319
+ ```
320
+ grep -q "^\[${BRAND}\]" /etc/samba/smb.conf && echo present || echo missing
321
+ ```
322
+
323
+ If `missing`, provision Samba first (the installer's post-install step
324
+ does this on every Pi). The script skips the SMB ingress with
325
+ `[tunnel-install] smb-ingress-skipped reason=samba-not-provisioned`
326
+ rather than failing.
327
+
328
+ **Access policy is dashboard-only.** Cloudflare API/SDK is forbidden;
329
+ `cloudflared` CLI has no Access-application create subcommand. After
330
+ the ingress is in place, author the policy in the dashboard:
331
+
332
+ ```
333
+ Cloudflare → Zero Trust → Access → Applications → Add → Self-hosted
334
+ Application name: ssh.maxy.bot (or smb.maxy.bot)
335
+ Application domain: ssh.maxy.bot (or smb.maxy.bot)
336
+ Policy → Action: Allow
337
+ Include → Emails: <operator email>
338
+ ```
339
+
340
+ Verify ingress validity:
341
+
342
+ ```
343
+ cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel ingress validate "${CFG_DIR}/config.yml"
344
+ ```
345
+
346
+ Then verify off-LAN reachability from a Mac:
347
+
348
+ ```
349
+ cloudflared access ssh --hostname ssh.<brand>.<rootdomain>
350
+ cloudflared access tcp --hostname smb.<brand>.<rootdomain> --url tcp://localhost:4445
351
+ # In Finder: Cmd-K → smb://localhost:4445
352
+ ```
353
+
282
354
  ---
283
355
 
284
356
  ## Step 5b — Write tunnel.state
@@ -300,11 +372,19 @@ cat > "${CFG_DIR}/tunnel.state" <<EOF
300
372
  "tunnelName": "${BRAND}-$(hostname -s)",
301
373
  "domain": "maxy.bot",
302
374
  "configPath": "${CFG_DIR}/config.yml",
303
- "credentialsPath": "${CFG_DIR}/${TUNNEL_ID}.json"
375
+ "credentialsPath": "${CFG_DIR}/${TUNNEL_ID}.json",
376
+ "sshHostname": "ssh.maxy.bot",
377
+ "smbHostname": "smb.maxy.bot"
304
378
  }
305
379
  EOF
306
380
  ```
307
381
 
382
+ Omit `sshHostname` and `smbHostname` when those ingresses are not in
383
+ use. The fields are additive; the script's re-run path rehydrates them
384
+ on every invocation so an operator running `setup-tunnel.sh` without
385
+ `SSH_HOSTNAME` / `SMB_HOSTNAME` env vars does not silently drop the
386
+ SSH/SMB ingress.
387
+
308
388
  **Why each field:** `resume-tunnel.sh` reads `tunnelId`, `domain`, `configPath` (all used; domain is for logging, tunnelId for the log line, configPath is passed as `--config` to cloudflared). `credentialsPath` and `tunnelName` are not read by `resume-tunnel.sh` itself but are consumed by other tools (e.g. `tunnel-status` in the cloudflared plugin), so write them anyway.
309
389
 
310
390
  **Success:**
@@ -0,0 +1,241 @@
1
+ // Task 009 — acceptance grid for tunnel-ingress.ts pure layer.
2
+ //
3
+ // Runs under `node --test --experimental-strip-types` (no compile step). No
4
+ // filesystem reads except via temp files written by the test itself; no
5
+ // network; no shell-outs. Mirrors apt-resolve / samba-provision style.
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import {
13
+ renderConfigYml,
14
+ renderTunnelState,
15
+ readPersistedHostnames,
16
+ probeSambaStanza,
17
+ renderAccessPolicyActionRequired,
18
+ type IngressSpec,
19
+ type TunnelState,
20
+ } from "../tunnel-ingress.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // renderConfigYml
24
+ // ---------------------------------------------------------------------------
25
+
26
+ test("renderConfigYml: HTTPS-only matches pre-Task-009 shape byte-for-byte", () => {
27
+ const spec: IngressSpec = {
28
+ tunnelId: "abc-123",
29
+ credentialsPath: "/home/admin/.maxy/cloudflared/abc-123.json",
30
+ httpPort: 19200,
31
+ httpHostnames: ["admin.maxy.bot", "public.maxy.bot"],
32
+ };
33
+ assert.equal(
34
+ renderConfigYml(spec),
35
+ [
36
+ "tunnel: abc-123",
37
+ "credentials-file: /home/admin/.maxy/cloudflared/abc-123.json",
38
+ "ingress:",
39
+ " - hostname: admin.maxy.bot",
40
+ " service: http://localhost:19200",
41
+ " - hostname: public.maxy.bot",
42
+ " service: http://localhost:19200",
43
+ " - service: http_status:404",
44
+ "",
45
+ ].join("\n"),
46
+ );
47
+ });
48
+
49
+ test("renderConfigYml: SSH+SMB appear AFTER http hostnames and BEFORE catch-all", () => {
50
+ const spec: IngressSpec = {
51
+ tunnelId: "t-id",
52
+ credentialsPath: "/creds.json",
53
+ httpPort: 19200,
54
+ httpHostnames: ["admin.maxy.bot"],
55
+ sshHostname: "ssh.maxy.bot",
56
+ smbHostname: "smb.maxy.bot",
57
+ };
58
+ const out = renderConfigYml(spec);
59
+ const idxHttp = out.indexOf("admin.maxy.bot");
60
+ const idxSsh = out.indexOf("ssh.maxy.bot");
61
+ const idxSmb = out.indexOf("smb.maxy.bot");
62
+ const idx404 = out.indexOf("http_status:404");
63
+ assert.ok(idxHttp < idxSsh, "HTTP before SSH");
64
+ assert.ok(idxSsh < idxSmb, "SSH before SMB");
65
+ assert.ok(idxSmb < idx404, "SMB before catch-all 404");
66
+ assert.match(out, /\s+service: ssh:\/\/localhost:22$/m);
67
+ assert.match(out, /\s+service: tcp:\/\/localhost:445$/m);
68
+ });
69
+
70
+ test("renderConfigYml: SSH only — no SMB block when smbHostname is null", () => {
71
+ const spec: IngressSpec = {
72
+ tunnelId: "t-id",
73
+ credentialsPath: "/creds.json",
74
+ httpPort: 19200,
75
+ httpHostnames: ["admin.maxy.bot"],
76
+ sshHostname: "ssh.maxy.bot",
77
+ smbHostname: null,
78
+ };
79
+ const out = renderConfigYml(spec);
80
+ assert.ok(out.includes("ssh.maxy.bot"));
81
+ assert.ok(!out.includes("tcp://localhost:445"));
82
+ });
83
+
84
+ test("renderConfigYml: empty string hostnames are treated as absent", () => {
85
+ const spec: IngressSpec = {
86
+ tunnelId: "t-id",
87
+ credentialsPath: "/creds.json",
88
+ httpPort: 19200,
89
+ httpHostnames: ["admin.maxy.bot"],
90
+ sshHostname: "",
91
+ smbHostname: "",
92
+ };
93
+ const out = renderConfigYml(spec);
94
+ assert.ok(!out.includes("ssh://localhost:22"));
95
+ assert.ok(!out.includes("tcp://localhost:445"));
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // renderTunnelState
100
+ // ---------------------------------------------------------------------------
101
+
102
+ test("renderTunnelState: pre-Task-009 fields preserved when ssh/smb absent", () => {
103
+ const state: TunnelState = {
104
+ tunnelId: "abc",
105
+ tunnelName: "maxy",
106
+ domain: "admin.maxy.bot",
107
+ configPath: "/home/admin/.maxy/cloudflared/config.yml",
108
+ credentialsPath: "/home/admin/.maxy/cloudflared/abc.json",
109
+ };
110
+ const parsed = JSON.parse(renderTunnelState(state));
111
+ assert.deepEqual(parsed, {
112
+ tunnelId: "abc",
113
+ tunnelName: "maxy",
114
+ domain: "admin.maxy.bot",
115
+ configPath: "/home/admin/.maxy/cloudflared/config.yml",
116
+ credentialsPath: "/home/admin/.maxy/cloudflared/abc.json",
117
+ });
118
+ });
119
+
120
+ test("renderTunnelState: ssh/smb hostnames persist when present", () => {
121
+ const state: TunnelState = {
122
+ tunnelId: "abc",
123
+ tunnelName: "maxy",
124
+ domain: "admin.maxy.bot",
125
+ configPath: "/cfg.yml",
126
+ credentialsPath: "/creds.json",
127
+ sshHostname: "ssh.maxy.bot",
128
+ smbHostname: "smb.maxy.bot",
129
+ };
130
+ const parsed = JSON.parse(renderTunnelState(state));
131
+ assert.equal(parsed.sshHostname, "ssh.maxy.bot");
132
+ assert.equal(parsed.smbHostname, "smb.maxy.bot");
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // readPersistedHostnames
137
+ // ---------------------------------------------------------------------------
138
+
139
+ test("readPersistedHostnames: returns nulls when file missing", () => {
140
+ const got = readPersistedHostnames("/nope/does/not/exist.json");
141
+ assert.deepEqual(got, { sshHostname: null, smbHostname: null });
142
+ });
143
+
144
+ test("readPersistedHostnames: round-trips ssh/smb hostnames from disk", () => {
145
+ const dir = mkdtempSync(join(tmpdir(), "tunnel-state-"));
146
+ const path = join(dir, "tunnel.state");
147
+ try {
148
+ writeFileSync(
149
+ path,
150
+ JSON.stringify({
151
+ tunnelId: "abc",
152
+ tunnelName: "maxy",
153
+ domain: "admin.maxy.bot",
154
+ configPath: "/cfg.yml",
155
+ credentialsPath: "/creds.json",
156
+ sshHostname: "ssh.maxy.bot",
157
+ smbHostname: "smb.maxy.bot",
158
+ }),
159
+ );
160
+ const got = readPersistedHostnames(path);
161
+ assert.deepEqual(got, { sshHostname: "ssh.maxy.bot", smbHostname: "smb.maxy.bot" });
162
+ } finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("readPersistedHostnames: malformed JSON returns nulls (no throw)", () => {
168
+ const dir = mkdtempSync(join(tmpdir(), "tunnel-state-bad-"));
169
+ const path = join(dir, "tunnel.state");
170
+ try {
171
+ writeFileSync(path, "not json");
172
+ assert.deepEqual(readPersistedHostnames(path), { sshHostname: null, smbHostname: null });
173
+ } finally {
174
+ rmSync(dir, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // probeSambaStanza
180
+ // ---------------------------------------------------------------------------
181
+
182
+ test("probeSambaStanza: true when [<brand>] header present", () => {
183
+ const dir = mkdtempSync(join(tmpdir(), "smb-probe-"));
184
+ const path = join(dir, "smb.conf");
185
+ try {
186
+ writeFileSync(path, "[global]\nworkgroup = WORKGROUP\n\n[maxy]\n path = /home/admin\n");
187
+ assert.equal(probeSambaStanza("maxy", path), true);
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test("probeSambaStanza: false when only peer brand present (isolation)", () => {
194
+ const dir = mkdtempSync(join(tmpdir(), "smb-probe-peer-"));
195
+ const path = join(dir, "smb.conf");
196
+ try {
197
+ writeFileSync(path, "[global]\n\n[realagent]\n path = /home/admin\n");
198
+ assert.equal(probeSambaStanza("maxy", path), false);
199
+ } finally {
200
+ rmSync(dir, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test("probeSambaStanza: false when smb.conf missing", () => {
205
+ assert.equal(probeSambaStanza("maxy", "/nope/smb.conf"), false);
206
+ });
207
+
208
+ test("probeSambaStanza: brand names with regex meta characters are escaped", () => {
209
+ const dir = mkdtempSync(join(tmpdir(), "smb-probe-regex-"));
210
+ const path = join(dir, "smb.conf");
211
+ try {
212
+ writeFileSync(path, "[some.brand]\n path = /x\n");
213
+ assert.equal(probeSambaStanza("some.brand", path), true);
214
+ assert.equal(probeSambaStanza("someXbrand", path), false);
215
+ } finally {
216
+ rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // renderAccessPolicyActionRequired
222
+ // ---------------------------------------------------------------------------
223
+
224
+ test("renderAccessPolicyActionRequired: empty string when neither hostname set", () => {
225
+ assert.equal(
226
+ renderAccessPolicyActionRequired({ operatorEmail: "x@y.com" }),
227
+ "",
228
+ );
229
+ });
230
+
231
+ test("renderAccessPolicyActionRequired: lists each hostname with operator email", () => {
232
+ const out = renderAccessPolicyActionRequired({
233
+ sshHostname: "ssh.maxy.bot",
234
+ smbHostname: "smb.maxy.bot",
235
+ operatorEmail: "joel@example.com",
236
+ });
237
+ assert.match(out, /SSH: ssh\.maxy\.bot/);
238
+ assert.match(out, /SMB: smb\.maxy\.bot/);
239
+ assert.match(out, /Emails: joel@example\.com/);
240
+ assert.match(out, /Zero Trust → Access → Applications/);
241
+ });
@@ -27,15 +27,20 @@
27
27
 
28
28
  set -euo pipefail
29
29
 
30
+ # Resolve symlinks before dirname — ~/setup-tunnel.sh is installed as a symlink
31
+ # (see packages/create-maxy-code/src/index.ts:installTunnelScripts), so the raw
32
+ # BASH_SOURCE[0] points at $HOME, not the scripts directory where the sibling
33
+ # helpers (_stream-log.sh, tunnel-ingress.ts) live. Factored to SCRIPT_DIR so
34
+ # Task 600's stream-log-contract scanner can statically resolve the
35
+ # tunnel-ingress.ts target (Task 054).
36
+ SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
37
+
30
38
  # --------------------------------------------------------------------------
31
39
  # Shared stream-log helpers (require STREAM_LOG_PATH, phase_line, …).
32
40
  # --------------------------------------------------------------------------
33
41
 
34
42
  # shellcheck source=_stream-log.sh
35
- # Resolve symlinks before dirname — ~/setup-tunnel.sh is installed as a symlink
36
- # (see packages/create-maxy-code/src/index.ts:installTunnelScripts), so the raw
37
- # BASH_SOURCE[0] points at $HOME, not the scripts directory where _stream-log.sh lives.
38
- source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_stream-log.sh"
43
+ source "${SCRIPT_DIR}/_stream-log.sh"
39
44
  require_stream_log_path setup-tunnel
40
45
 
41
46
  # --------------------------------------------------------------------------
@@ -53,7 +58,39 @@ PORT="$2"
53
58
  shift 2
54
59
  HOSTNAMES=("$@")
55
60
 
56
- phase_line setup-tunnel step=start brand="${BRAND}" port="${PORT}" hostnames="${HOSTNAMES[*]}"
61
+ # --------------------------------------------------------------------------
62
+ # Task 009 — SSH/SMB ingress env vars.
63
+ #
64
+ # SSH and SMB hostnames arrive via environment, NOT positional argv, so the
65
+ # existing form/endpoint contract (which passes admin/public/apex as
66
+ # positionals) keeps working unchanged. The action-runner sets these env
67
+ # vars when the operator submits the cloudflare-setup form with the SSH
68
+ # and SMB fields populated; absent env vars mean "no SSH/SMB ingress this
69
+ # run" — except when tunnel.state already persists them, in which case
70
+ # rehydrate from there.
71
+ #
72
+ # SSH_HOSTNAME e.g. ssh.maxy.bot → ingress service: ssh://localhost:22
73
+ # SMB_HOSTNAME e.g. smb.maxy.bot → ingress service: tcp://localhost:445
74
+ # OPERATOR_EMAIL e.g. joel@x.com → printed in the ACTION REQUIRED
75
+ # dashboard click-path for the
76
+ # Access policy (operator-authored;
77
+ # CF API is banned per
78
+ # feedback_cf_api_total_eradication)
79
+ #
80
+ # Re-run idempotency: if env vars are unset, the script reads sshHostname /
81
+ # smbHostname from the existing tunnel.state (if present) so a re-run
82
+ # without env vars doesn't silently drop the SSH/SMB ingress. tunnel.state
83
+ # is rewritten with the resolved hostnames at the end of Step 5b.
84
+ # --------------------------------------------------------------------------
85
+
86
+ SSH_HOSTNAME="${SSH_HOSTNAME:-}"
87
+ SMB_HOSTNAME="${SMB_HOSTNAME:-}"
88
+ OPERATOR_EMAIL="${OPERATOR_EMAIL:-}"
89
+ SETUP_TUNNEL_DRY_RUN="${SETUP_TUNNEL_DRY_RUN:-0}"
90
+
91
+ phase_line setup-tunnel step=start brand="${BRAND}" port="${PORT}" hostnames="${HOSTNAMES[*]}" \
92
+ ssh_hostname="${SSH_HOSTNAME:-none}" smb_hostname="${SMB_HOSTNAME:-none}" \
93
+ dry_run="${SETUP_TUNNEL_DRY_RUN}"
57
94
 
58
95
  # --------------------------------------------------------------------------
59
96
  # Step 0: Set brand context (paths + dirs). Corresponds to runbook Step 0.
@@ -636,39 +673,241 @@ for H in "${HOSTNAMES[@]}"; do
636
673
  done
637
674
 
638
675
  # --------------------------------------------------------------------------
639
- # Step 5: Write config.yml. Every hostname (apex + subdomain) gets an
640
- # ingress rule — apex still needs the rule for the connector to serve
641
- # traffic once DNS is manually pointed.
676
+ # Task 009 Rehydrate SSH/SMB hostnames from tunnel.state when env unset.
677
+ #
678
+ # A re-run without SSH_HOSTNAME / SMB_HOSTNAME should keep the previously
679
+ # configured ingress rather than silently dropping them. The Node helper
680
+ # reads tunnel.state (if present) and prints a JSON {sshHostname, smbHostname}.
642
681
  # --------------------------------------------------------------------------
643
682
 
683
+ TUNNEL_INGRESS_TS="${SCRIPT_DIR}/tunnel-ingress.ts"
684
+ if [ ! -f "${TUNNEL_INGRESS_TS}" ]; then
685
+ phase_line setup-tunnel step=ingress-renderer-resolve result=error \
686
+ reason=helper-missing path="${TUNNEL_INGRESS_TS}"
687
+ echo "ERROR: tunnel-ingress.ts is missing at ${TUNNEL_INGRESS_TS}" >&2
688
+ exit 1
689
+ fi
690
+
691
+ if [ -z "${SSH_HOSTNAME}${SMB_HOSTNAME}" ] && [ -f "${CFG_DIR}/tunnel.state" ]; then
692
+ PERSISTED_JSON="$(node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" read-state "${CFG_DIR}/tunnel.state" 2>/dev/null || echo '{}')"
693
+ 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{}})')"
694
+ 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{}})')"
695
+ if [ -n "${PERSISTED_SSH}" ]; then
696
+ SSH_HOSTNAME="${PERSISTED_SSH}"
697
+ phase_line setup-tunnel step=ssh-hostname-rehydrated hostname="${SSH_HOSTNAME}"
698
+ fi
699
+ if [ -n "${PERSISTED_SMB}" ]; then
700
+ SMB_HOSTNAME="${PERSISTED_SMB}"
701
+ phase_line setup-tunnel step=smb-hostname-rehydrated hostname="${SMB_HOSTNAME}"
702
+ fi
703
+ fi
704
+
705
+ # --------------------------------------------------------------------------
706
+ # Task 009 — Samba presence probe (gates SMB ingress on Task 034 stanza).
707
+ #
708
+ # Per Task 034, the brand's Samba stanza `[<brand>]` is written to
709
+ # /etc/samba/smb.conf at install time. If the stanza is absent, this Pi
710
+ # has no SMB share to expose and the SMB ingress is skipped loudly.
711
+ # Probe via grep on the Pi filesystem (NOT brand.json — Samba is a
712
+ # post-install side effect, not a declared field).
713
+ # --------------------------------------------------------------------------
714
+
715
+ if [ -n "${SMB_HOSTNAME}" ]; then
716
+ if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" probe-samba "${BRAND}" /etc/samba/smb.conf >/dev/null 2>&1; then
717
+ phase_line setup-tunnel step=smb-ingress-skipped reason=samba-not-provisioned \
718
+ hostname="${SMB_HOSTNAME}" brand="${BRAND}"
719
+ echo "[tunnel-install] smb-ingress-skipped reason=samba-not-provisioned brand=${BRAND}"
720
+ echo " Skipping SMB ingress: /etc/samba/smb.conf has no [${BRAND}] stanza."
721
+ echo " Provision Samba first (the installer's post-install step does this); then re-run."
722
+ SMB_HOSTNAME=""
723
+ fi
724
+ fi
725
+
726
+ # --------------------------------------------------------------------------
727
+ # Task 009 — Step 4b: Route DNS for SSH/SMB hostnames (after HTTPS pass).
728
+ #
729
+ # Two-phase ordering: HTTPS hostnames are routed first (loop above). If a
730
+ # subsequent SSH or SMB route DNS fails, emit `*-ingress-deferred` and
731
+ # continue WITHOUT exit 1, so the HTTPS ingress remains durable. Apex
732
+ # hostnames are not valid for SSH/SMB (the hostnames are subdomains by
733
+ # definition); no apex carve-out is needed.
734
+ # --------------------------------------------------------------------------
735
+
736
+ route_extra_hostname() {
737
+ local kind="$1" # "ssh" or "smb"
738
+ local hostname="$2"
739
+ [ -z "${hostname}" ] && return 0
740
+
741
+ if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
742
+ phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" result=dry-run-skip
743
+ return 0
744
+ fi
745
+
746
+ phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" tunnel_id="${TUNNEL_ID}"
747
+ local ROUTE_LOG
748
+ ROUTE_LOG="$(mktemp -t "maxy-${kind}-route-dns.XXXXXX")"
749
+ if tee_subprocess_capture "setup-tunnel:cloudflared" -- \
750
+ cloudflared --origincert "${CFG_DIR}/cert.pem" \
751
+ tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${hostname}" \
752
+ > "${ROUTE_LOG}"; then
753
+ phase_line setup-tunnel step="${kind}-route-dns" hostname="${hostname}" result=ok
754
+ rm -f "${ROUTE_LOG}"
755
+ return 0
756
+ fi
757
+ local ROUTE_RC=$?
758
+ local STDERR_BOUNDED
759
+ STDERR_BOUNDED="$(tr '\n' ' ' < "${ROUTE_LOG}" | head -c 400)"
760
+ phase_line setup-tunnel step="${kind}-ingress-deferred" hostname="${hostname}" \
761
+ reason=route-dns-failed exit="${ROUTE_RC}" stderr="${STDERR_BOUNDED}"
762
+ echo "WARNING: cloudflared tunnel route dns failed for ${hostname} (exit=${ROUTE_RC})." >&2
763
+ echo " HTTPS ingress remains durable; the ${kind} ingress is deferred." >&2
764
+ rm -f "${ROUTE_LOG}"
765
+ # Clear the hostname so the config.yml render below skips this entry.
766
+ if [ "${kind}" = "ssh" ]; then SSH_HOSTNAME=""; else SMB_HOSTNAME=""; fi
767
+ return 0
768
+ }
769
+
770
+ route_extra_hostname ssh "${SSH_HOSTNAME}"
771
+ route_extra_hostname smb "${SMB_HOSTNAME}"
772
+
773
+ # --------------------------------------------------------------------------
774
+ # Step 5: Write config.yml via the unit-tested Node renderer. Every HTTPS
775
+ # hostname (apex + subdomain) gets an ingress rule — apex still needs the
776
+ # rule for the connector to serve traffic once DNS is manually pointed.
777
+ # SSH and SMB rules are inserted between the HTTPS rules and the catch-all
778
+ # http_status:404 (renderer enforces ordering).
779
+ # --------------------------------------------------------------------------
780
+
781
+ INGRESS_SPEC_FILE="$(mktemp -t maxy-ingress-spec.XXXXXX.json)"
644
782
  {
645
- echo "tunnel: ${TUNNEL_ID}"
646
- echo "credentials-file: ${CFG_DIR}/${TUNNEL_ID}.json"
647
- echo "ingress:"
783
+ printf '{'
784
+ printf '"tunnelId":"%s",' "${TUNNEL_ID}"
785
+ printf '"credentialsPath":"%s",' "${CFG_DIR}/${TUNNEL_ID}.json"
786
+ printf '"httpPort":%s,' "${PORT}"
787
+ printf '"httpHostnames":['
788
+ FIRST=1
648
789
  for H in "${HOSTNAMES[@]}"; do
649
- echo " - hostname: ${H}"
650
- echo " service: http://localhost:${PORT}"
790
+ if [ ${FIRST} -eq 1 ]; then FIRST=0; else printf ','; fi
791
+ printf '"%s"' "${H}"
651
792
  done
652
- echo " - service: http_status:404"
653
- } > "${CFG_DIR}/config.yml"
654
- echo "wrote ${CFG_DIR}/config.yml"
793
+ printf '],'
794
+ printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
795
+ printf '"smbHostname":"%s"' "${SMB_HOSTNAME}"
796
+ printf '}'
797
+ } > "${INGRESS_SPEC_FILE}"
798
+
799
+ if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
800
+ echo "--- DRY RUN: rendered config.yml ---"
801
+ node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-config "${INGRESS_SPEC_FILE}"
802
+ echo "--- DRY RUN: end ---"
803
+ phase_line setup-tunnel step=config-yml-write result=dry-run-skip
804
+ else
805
+ if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-config "${INGRESS_SPEC_FILE}" > "${CFG_DIR}/config.yml"; then
806
+ phase_line setup-tunnel step=config-yml-write result=error reason=renderer-failed
807
+ echo "ERROR: tunnel-ingress render-config failed" >&2
808
+ rm -f "${INGRESS_SPEC_FILE}"
809
+ exit 1
810
+ fi
811
+ echo "wrote ${CFG_DIR}/config.yml"
812
+ fi
813
+ rm -f "${INGRESS_SPEC_FILE}"
814
+
815
+ if [ -n "${SSH_HOSTNAME}" ]; then
816
+ phase_line setup-tunnel step=ssh-ingress-added hostname="${SSH_HOSTNAME}" service=ssh://localhost:22
817
+ echo "[tunnel-install] ssh-ingress-added hostname=${SSH_HOSTNAME} service=ssh://localhost:22"
818
+ fi
819
+ if [ -n "${SMB_HOSTNAME}" ]; then
820
+ phase_line setup-tunnel step=smb-ingress-added hostname="${SMB_HOSTNAME}" service=tcp://localhost:445
821
+ echo "[tunnel-install] smb-ingress-added hostname=${SMB_HOSTNAME} service=tcp://localhost:445"
822
+ fi
655
823
 
656
824
  # --------------------------------------------------------------------------
657
- # Step 5b: Write tunnel.state. Required by resume-tunnel.sh without it
658
- # the brand.service's ExecStartPre exits silently without spawning a
659
- # connector.
825
+ # Step 5b: Write tunnel.state via the same renderer. Additive sshHostname /
826
+ # smbHostname fields let the next re-run rehydrate them. Required by
827
+ # resume-tunnel.sh — without it the brand.service's ExecStartPre exits
828
+ # silently without spawning a connector.
660
829
  # --------------------------------------------------------------------------
661
830
 
662
- cat > "${CFG_DIR}/tunnel.state" <<EOF
831
+ STATE_SPEC_FILE="$(mktemp -t maxy-tunnel-state.XXXXXX.json)"
663
832
  {
664
- "tunnelId": "${TUNNEL_ID}",
665
- "tunnelName": "${TUNNEL_NAME}",
666
- "domain": "${HOSTNAMES[0]}",
667
- "configPath": "${CFG_DIR}/config.yml",
668
- "credentialsPath": "${CFG_DIR}/${TUNNEL_ID}.json"
669
- }
670
- EOF
671
- echo "wrote ${CFG_DIR}/tunnel.state"
833
+ printf '{'
834
+ printf '"tunnelId":"%s",' "${TUNNEL_ID}"
835
+ printf '"tunnelName":"%s",' "${TUNNEL_NAME}"
836
+ printf '"domain":"%s",' "${HOSTNAMES[0]}"
837
+ printf '"configPath":"%s",' "${CFG_DIR}/config.yml"
838
+ printf '"credentialsPath":"%s",' "${CFG_DIR}/${TUNNEL_ID}.json"
839
+ printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
840
+ printf '"smbHostname":"%s"' "${SMB_HOSTNAME}"
841
+ printf '}'
842
+ } > "${STATE_SPEC_FILE}"
843
+
844
+ if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
845
+ echo "--- DRY RUN: rendered tunnel.state ---"
846
+ node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-state "${STATE_SPEC_FILE}"
847
+ echo "--- DRY RUN: end ---"
848
+ phase_line setup-tunnel step=tunnel-state-write result=dry-run-skip
849
+ else
850
+ if ! node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" render-state "${STATE_SPEC_FILE}" > "${CFG_DIR}/tunnel.state"; then
851
+ phase_line setup-tunnel step=tunnel-state-write result=error reason=renderer-failed
852
+ echo "ERROR: tunnel-ingress render-state failed" >&2
853
+ rm -f "${STATE_SPEC_FILE}"
854
+ exit 1
855
+ fi
856
+ echo "wrote ${CFG_DIR}/tunnel.state"
857
+ fi
858
+ rm -f "${STATE_SPEC_FILE}"
859
+
860
+ # --------------------------------------------------------------------------
861
+ # Task 009 — Access policy ACTION REQUIRED.
862
+ #
863
+ # The Cloudflare Access policy that gates these new SSH/SMB hostnames must
864
+ # be authored in the dashboard by the operator: CF API/SDK is banned
865
+ # (feedback_cf_api_total_eradication) and `cloudflared` CLI has no
866
+ # Access-application create subcommand. Print the click-path with the
867
+ # resolved hostnames + operator email substituted in. operatorEmail comes
868
+ # from OPERATOR_EMAIL env, falling back to brand.json `.operatorEmail`.
869
+ # --------------------------------------------------------------------------
870
+
871
+ if [ -n "${SSH_HOSTNAME}${SMB_HOSTNAME}" ]; then
872
+ RESOLVED_EMAIL="${OPERATOR_EMAIL}"
873
+ if [ -z "${RESOLVED_EMAIL}" ] && [ -n "${SETUP_TUNNEL_BRAND_JSON}" ] && command -v jq >/dev/null 2>&1; then
874
+ RESOLVED_EMAIL="$(jq -r '.operatorEmail // empty' "${SETUP_TUNNEL_BRAND_JSON}" 2>/dev/null || true)"
875
+ fi
876
+ if [ -z "${RESOLVED_EMAIL}" ]; then
877
+ RESOLVED_EMAIL="<operator email — set OPERATOR_EMAIL or brand.json .operatorEmail>"
878
+ phase_line setup-tunnel step=operator-email-unresolved reason=env-and-brand-json-missing
879
+ fi
880
+
881
+ if [ -n "${SSH_HOSTNAME}" ]; then
882
+ phase_line setup-tunnel step=ssh-access-policy-required hostname="${SSH_HOSTNAME}" allow_emails="${RESOLVED_EMAIL}"
883
+ echo "[tunnel-install] ssh-access-policy-required hostname=${SSH_HOSTNAME} allow-emails=${RESOLVED_EMAIL}"
884
+ fi
885
+ if [ -n "${SMB_HOSTNAME}" ]; then
886
+ phase_line setup-tunnel step=smb-access-policy-required hostname="${SMB_HOSTNAME}" allow_emails="${RESOLVED_EMAIL}"
887
+ echo "[tunnel-install] smb-access-policy-required hostname=${SMB_HOSTNAME} allow-emails=${RESOLVED_EMAIL}"
888
+ fi
889
+
890
+ AR_INPUT_FILE="$(mktemp -t maxy-access-req.XXXXXX.json)"
891
+ {
892
+ printf '{'
893
+ printf '"sshHostname":"%s",' "${SSH_HOSTNAME}"
894
+ printf '"smbHostname":"%s",' "${SMB_HOSTNAME}"
895
+ printf '"operatorEmail":"%s"' "${RESOLVED_EMAIL}"
896
+ printf '}'
897
+ } > "${AR_INPUT_FILE}"
898
+ node --experimental-strip-types --no-warnings "${TUNNEL_INGRESS_TS}" action-req "${AR_INPUT_FILE}" || true
899
+ rm -f "${AR_INPUT_FILE}"
900
+ fi
901
+
902
+ # --------------------------------------------------------------------------
903
+ # Dry-run short-circuit: exit before onboarding / service restart.
904
+ # --------------------------------------------------------------------------
905
+ if [ "${SETUP_TUNNEL_DRY_RUN}" = "1" ]; then
906
+ phase_line setup-tunnel step=done dry_run=1
907
+ echo ""
908
+ echo "DRY RUN complete. No cloudflared mutations made; no service restart."
909
+ exit 0
910
+ fi
672
911
 
673
912
  # --------------------------------------------------------------------------
674
913
  # Step 6: Persist onboarding step-7 completion BEFORE arming the restart.