@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,819 @@
1
+ // Relative imports, not the package's own subpaths: this entry is on the SDK's
2
+ // exports map, so an author bundle can pull it into QuickJS, and everything it
3
+ // reaches must be a leaf that touches no host global
4
+ // (`worker-protocol/quickjs-safe.test.ts` keeps that honest).
5
+ import { HOME_COOKIE_PREFIX } from "../../../home-cookie.ts";
6
+ import { structuredHost } from "../../../service-url.ts";
7
+ import type { IngressInput } from "../../../worker-protocol/ingress.ts";
8
+ // The leaf, never `worker-protocol/uplink/index.ts`: the barrel pulls the
9
+ // session layer and `@yourrealm/noise` in, neither of which belongs in a
10
+ // bundle this module can be reached from.
11
+ import { REKEY_INTERVAL_MS, REKEY_JITTER_MS } from "../../../worker-protocol/uplink/constants.ts";
12
+ import type {
13
+ CaddyConfig,
14
+ CaddyDynamicDns,
15
+ CaddyHandler,
16
+ CaddyHeaderRewrite,
17
+ CaddyIssuer,
18
+ CaddyProvider,
19
+ CaddyErrorRoute,
20
+ CaddyRoute,
21
+ CaddyTlsPolicy,
22
+ } from "./types.ts";
23
+
24
+ // Caddy JSON builder - the Docker worker's ingress renderer, the `ingress`
25
+ // half of what `convertToDockerCompose` is for `apps`.
26
+ //
27
+ // Every routed service gets an authenticated subdomain on the worker's
28
+ // wildcard (`{appId}-{service}.{workerId}.{baseDomain}`), gated by Home
29
+ // ForwardAuth. A `router.publicDomain`, if present, is an *additional*
30
+ // ungated public route.
31
+
32
+ // Home ForwardAuth verify endpoint. Caddy can't dial Home itself: Home's
33
+ // container name (`realm-home`) only resolves on the box that runs Home, and a
34
+ // remote worker reaches Home over its own uplink — so a direct upstream would
35
+ // 503 every gated route there. Caddy dials the local worker's verify relay over
36
+ // the management bridge instead, and the worker forwards this one path to Home.
37
+ const VERIFY_UPSTREAM = "realm-worker:6021";
38
+ const VERIFY_PATH = "/api/access/verify";
39
+
40
+ // `X-Realm-*` is the platform's own header namespace: Home and the worker
41
+ // stamp into it and containers are told to trust what arrives there, so no
42
+ // client may set any of it — including the platform headers an app never
43
+ // declared, which the declared keys alone would miss. Caddy's `delete` takes
44
+ // prefix, suffix and substring wildcards, so one entry covers the namespace,
45
+ // and it never overlaps the declared keys beside it: `getTrustedHeaders`
46
+ // rejects a name in this namespace at sampling time.
47
+ const REALM_HEADER_WILDCARD = "X-Realm-*";
48
+
49
+ // Matches any of Home's cookies (the `realm-home.*` namespace Home's
50
+ // better-auth config claims via HOME_COOKIE_PREFIX) in a `Cookie` header,
51
+ // with or without the `__Secure-` prefix. The namespace holds two: the
52
+ // host-only session cookie that drives the dashboard, and the identity cookie
53
+ // scoped to `.{workerSlug}.{baseDomain}` that the forward_auth hop reads.
54
+ // Caddy's regexp replace removes every match, so an app sees neither, while
55
+ // the app's own cookies pass through — including an app's better-auth cookies
56
+ // under the library's default `better-auth.*` names, which is why Home does
57
+ // not use them.
58
+ const HOME_COOKIE_REGEX = `(__Secure-)?${HOME_COOKIE_PREFIX}\\.[^=]+=[^;]*;?\\s*`;
59
+
60
+ // Matches a whole `Set-Cookie` value whose cookie NAME is one of Home's.
61
+ // Anchored at both ends so an app cookie that merely contains the string
62
+ // somewhere in its value is left alone; only a cookie actually named
63
+ // `realm-home.*` is blanked.
64
+ //
65
+ // The response-direction twin of HOME_COOKIE_REGEX. The identity cookie is
66
+ // scoped to `.{workerSlug}.{baseDomain}` and minted by the gate itself
67
+ // (`access-gate.ts`, on a response served at the app host, which is the only
68
+ // place that scope can be set from). A cookie may be set for any parent of the
69
+ // setting host that isn't a public suffix, so WITHOUT this an app on
70
+ // `{appId}-{service}.{workerSlug}.{baseDomain}` can answer with `Set-Cookie:
71
+ // realm-home.app_identity=…; Domain=.{workerSlug}.{baseDomain}` and replace the
72
+ // operator's real one — a forced logout at minimum, and at worst a fixated
73
+ // value the gate resolves to whoever the app chose. The blank is
74
+ // namespace-wide, so the session cookie's name is covered by the same pattern.
75
+ // `__Host-` would forbid the Domain attribute outright, but the identity
76
+ // cookie needs that Domain to reach the gate at all; the session cookie is
77
+ // host-only already.
78
+ //
79
+ // Both regexes key on the cookie NAME, so the per-worker scope changed nothing
80
+ // here — no ingress config moves with it.
81
+ const HOME_SET_COOKIE_REGEX = `^(__Secure-)?${HOME_COOKIE_PREFIX}\\.[^=]*=.*$`;
82
+
83
+ // Must stay brace-free: Caddy runs a static_response body through its
84
+ // placeholder replacer, where `{` opens a placeholder, so a `<style>` block
85
+ // would be parsed as one rather than served. Hence inline styles, and
86
+ // `color-scheme` + the `Canvas`/`CanvasText` system colors for light and dark
87
+ // without a media query.
88
+ //
89
+ // Reflects nothing from the request: `Host` is attacker-controlled and Home's
90
+ // identity cookie is scoped to `.{workerSlug}.{baseDomain}`, so echoing it
91
+ // would put an XSS inside that cookie's scope.
92
+ function holdingPage(title: string, headline: string, detail: string): string {
93
+ return [
94
+ `<!doctype html>`,
95
+ `<html lang="en" style="color-scheme: light dark">`,
96
+ `<meta charset="utf-8">`,
97
+ `<meta name="viewport" content="width=device-width,initial-scale=1">`,
98
+ `<meta http-equiv="refresh" content="5">`,
99
+ `<title>${title}</title>`,
100
+ `<body style="background: Canvas; color: CanvasText; font-family: system-ui, sans-serif;` +
101
+ ` display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 2rem;` +
102
+ ` box-sizing: border-box; text-align: center">`,
103
+ `<main style="max-width: 32rem">`,
104
+ `<h1 style="font-size: 1.25rem; font-weight: 600; margin: 0 0 0.75rem">${headline}</h1>`,
105
+ `<p style="margin: 0; opacity: 0.7; line-height: 1.5">${detail}</p>`,
106
+ `</main>`,
107
+ ].join("");
108
+ }
109
+
110
+ // Route present, upstream not answering: real app, container not up yet.
111
+ const DEPLOYING_PAGE = holdingPage(
112
+ "Starting up",
113
+ "This app is starting up",
114
+ "Its container is not answering yet. This page reloads on its own.",
115
+ );
116
+
117
+ // Set on the request once Home has answered the forward_auth hop AND approved.
118
+ // Absent on a route that has one means the dial to Home itself failed, which is
119
+ // what separates "this app is down" from "Realm's control plane is down".
120
+ const AUTH_OK_VAR = "realm_auth_ok";
121
+
122
+ // The forward_auth dial failed, so Home never answered. The app may well be
123
+ // fine — every subdomain on this worker fails the same way, because they all
124
+ // verify against the same Home.
125
+ const HOME_UNREACHABLE_PAGE = holdingPage(
126
+ "Realm is unreachable",
127
+ "Realm's control plane is not responding",
128
+ "Apps cannot be reached until it is back, even the ones still running.",
129
+ );
130
+
131
+ // No route at all: a name that never existed, or an app removed since the link
132
+ // was made. Says less because Caddy knows less.
133
+ const NOT_ROUTED_PAGE = holdingPage(
134
+ "Nothing here",
135
+ "Nothing is serving this address",
136
+ "No app on this worker answers to this name.",
137
+ );
138
+
139
+ // The Digital Asset Links statement the companion app's Trusted Web Activity
140
+ // is verified against. Chrome fetches `/.well-known/assetlinks.json` from the
141
+ // exact origin it is opening, without cookies, and drops its toolbar only when
142
+ // the statement names the app's package and signing key. Every host the
143
+ // ingress serves has to answer it - the dashboard, each app subdomain, each
144
+ // public domain - because the upstream apps never will and the ingress cannot
145
+ // inject one.
146
+ //
147
+ // The package name is a constant - a property of the published app, the same
148
+ // wherever it is installed. The fingerprints are not: which certificate signs
149
+ // the APK an operator's users actually run depends on how they got it, so they
150
+ // are operator-set on Home and ride the wire, identical on every box of one
151
+ // realm. An empty list is a valid statement that matches no app, so the route,
152
+ // its ordering and its anonymity hold on a realm that has set none.
153
+ //
154
+ // The body is JSON, braces and all: static_response runs it through Caddy's
155
+ // ReplaceKnown, which writes a brace span back verbatim unless the whole span
156
+ // names a placeholder it knows. The span IS the key - Caddy's replacer does not
157
+ // split it on the `:` a JSON object is full of, and its three providers answer
158
+ // only `env.*`, `file.*`, a handful of `system.*`/`time.*` names and what the
159
+ // HTTP server put in the request's own table. The only interpolated value is
160
+ // schema-validated hex and colons, which can never form a placeholder either.
161
+ // No span here resolves, so the statement reaches the wire byte for byte.
162
+ const ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
163
+ const COMPANION_PACKAGE = "eu.yourrealm.companion";
164
+
165
+ function assetLinksBody(fingerprints: readonly string[]): string {
166
+ return JSON.stringify([
167
+ {
168
+ relation: ["delegate_permission/common.handle_all_urls"],
169
+ target: {
170
+ namespace: "android_app",
171
+ package_name: COMPANION_PACKAGE,
172
+ sha256_cert_fingerprints: fingerprints,
173
+ },
174
+ },
175
+ ]);
176
+ }
177
+
178
+ const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
179
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
180
+ "X-Frame-Options": "SAMEORIGIN",
181
+ "X-Content-Type-Options": "nosniff",
182
+ "Referrer-Policy": "strict-origin-when-cross-origin",
183
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
184
+ };
185
+
186
+ // The same defaults in Caddy's header-map shape (one list per name). Every
187
+ // response the ingress writes itself - the holding pages, the asset links
188
+ // statement - is a static_response outside `buildContentHandlers`, so each one
189
+ // carries them from here rather than from a route chain.
190
+ function securityHeaderLists(): Record<string, string[]> {
191
+ return Object.fromEntries(Object.entries(DEFAULT_SECURITY_HEADERS).map(([k, v]) => [k, [v]]));
192
+ }
193
+
194
+ // In-container path the issuer below points at for the ACME root PEM.
195
+ // Exported because whoever materializes that file (the worker, into the
196
+ // directory the ingress mounts at /etc/caddy) has to use the same string.
197
+ export const ACME_CA_ROOT_PATH = "/etc/caddy/acme-ca-root.pem";
198
+
199
+ // How long an in-flight upgraded stream outlives a config reload. Remote
200
+ // workers hold their uplink through the dashboard route, and every deploy on
201
+ // Home's box reloads Caddy, which force-closes upgraded streams; without this
202
+ // every deploy would bounce every remote worker. The bound is the longest a
203
+ // session is allowed to live - the rekey interval plus its jitter - rounded up
204
+ // to whole hours, so a stream is never cut before it would have reconnected
205
+ // anyway.
206
+ const STREAM_CLOSE_DELAY = `${Math.ceil((REKEY_INTERVAL_MS + REKEY_JITTER_MS) / 3_600_000)}h`;
207
+
208
+ // The credential half of the acme-dns DNS-01 issuer. Creds are inlined into the
209
+ // issuer config (see buildAcmeDnsIssuer for why the env-placeholder form can't
210
+ // be used).
211
+ type AcmeIssuerInput = NonNullable<IngressInput["acmeDns"]>;
212
+
213
+ type IngressRoutedApp = IngressInput["routedApps"][number];
214
+ type IngressRouter = IngressRoutedApp["services"][number]["router"];
215
+ type PublicProviderInput = IngressInput["publicProviders"][number];
216
+
217
+ /**
218
+ * The renderer's parameter: the wire's `ingress` field.
219
+ *
220
+ * `IngressInput` verbatim - the point of the wire being typed is that this
221
+ * function's input IS the protocol, not a parallel shape that can drift from
222
+ * it. The three arrays are re-declared only to accept `readonly` ones, since
223
+ * nothing here mutates its input.
224
+ */
225
+ export type BuildInput = Omit<
226
+ IngressInput,
227
+ "routedApps" | "publicProviders" | "companionFingerprints"
228
+ > & {
229
+ readonly routedApps: readonly IngressRoutedApp[];
230
+ readonly publicProviders: readonly PublicProviderInput[];
231
+ readonly companionFingerprints: readonly string[];
232
+ };
233
+
234
+ export function buildCaddyConfig(input: BuildInput): CaddyConfig {
235
+ const { routedApps, dashboardHost, workerId, baseDomain, acmeDns, publicProviders } = input;
236
+ const { cdnProviders, companionFingerprints } = input;
237
+ const routes: CaddyRoute[] = [];
238
+
239
+ // The placeholder upstreams + the rate-limiter read the client IP from.
240
+ // Caddy stores the resolved client IP in the request's vars table under
241
+ // "client_ip" when server-level `trusted_proxies` is configured. The
242
+ // JSON-config placeholder is `{http.vars.client_ip}` — `{client_ip}` is a
243
+ // Caddyfile-only shorthand that doesn't exist in JSON config. With no
244
+ // trusted proxies the placeholder stays literal, so on the default path
245
+ // (Caddy IS the edge) `{http.request.remote.host}` always resolves.
246
+ const clientIp = cdnProviders.length > 0 ? "{http.vars.client_ip}" : "{http.request.remote.host}";
247
+
248
+ // First route, matched on path alone, so it answers on every host ahead of
249
+ // the dashboard proxy, the forward_auth hop and the wildcard holding page.
250
+ // Chrome fetches it anonymously from each origin it opens: behind the gate
251
+ // it would bounce to login, behind the wildcard it would be the not-routed
252
+ // page on any host without an app.
253
+ routes.push({
254
+ match: [{ path: [ASSET_LINKS_PATH] }],
255
+ handle: [
256
+ {
257
+ handler: "static_response",
258
+ status_code: 200,
259
+ headers: {
260
+ "Content-Type": ["application/json"],
261
+ ...securityHeaderLists(),
262
+ },
263
+ body: assetLinksBody(companionFingerprints),
264
+ },
265
+ ],
266
+ terminal: true,
267
+ });
268
+
269
+ // Home dashboard route, on the box that runs Home and nowhere else. The
270
+ // upstream `realm-home:3100` only resolves there, on the management bridge
271
+ // both containers join; elsewhere the dial misses and the request falls into
272
+ // the `errors` block below, which would serve a page saying the control plane
273
+ // is down - about a box that has no control plane to be down. Under BYO
274
+ // public providers it would also cost a real per-host certificate per worker.
275
+ //
276
+ // This is also the route every remote worker's uplink rides, which is why it
277
+ // is the one route carrying `stream_close_delay`.
278
+ if (dashboardHost !== null) {
279
+ routes.push({
280
+ match: [{ host: [dashboardHost] }],
281
+ handle: [homeProxy("realm-home:3100", clientIp)],
282
+ terminal: true,
283
+ });
284
+ }
285
+
286
+ for (const app of routedApps) {
287
+ for (const svc of app.services) {
288
+ const router = svc.router;
289
+ const upstream = `${app.appId}_${svc.serviceId}:${router.containerPort}`;
290
+ const host = structuredHost(app.appId, svc.serviceId, workerId, baseDomain);
291
+ routes.push(buildAuthRoute(host, router, upstream, app.trustedHeaderKeys ?? [], clientIp));
292
+ // An *additional*, ungated public route on the operator's own domain.
293
+ // Broker headers are stripped there so the bypass can't spoof identity.
294
+ if (router.publicDomain) {
295
+ const domains = Array.isArray(router.publicDomain)
296
+ ? router.publicDomain.map(String)
297
+ : [String(router.publicDomain)];
298
+ pushRoutes(routes, domains, router, upstream, app.trustedHeaderKeys ?? [], clientIp);
299
+ }
300
+ }
301
+ }
302
+
303
+ // Last route, so every specific host above wins: the worker's own wildcard,
304
+ // answering for a structured name that has no route yet (NOT_ROUTED_PAGE).
305
+ // Scoped to the names this worker mints — an unrelated Host still falls to
306
+ // Caddy's no-match default, which is an empty 200.
307
+ //
308
+ // 503 + Retry-After rather than 404, because the common way to land here is
309
+ // clicking a dashboard tile for an app whose reconcile is still running. A
310
+ // deleted or mistyped name gets the generous answer in exchange, which is
311
+ // cheap: these hostnames are generated and clicked, not typed.
312
+ //
313
+ // The wildcard also joins Caddy's managed-cert set by appearing in a matcher:
314
+ // a no-op when acme-dns already lists it in `certificates.automate` below,
315
+ // otherwise the catch-all policy issues it like any structured host.
316
+ if (workerId && baseDomain) {
317
+ routes.push({
318
+ match: [{ host: [`*.${workerId}.${baseDomain}`] }],
319
+ handle: [holdingResponse(NOT_ROUTED_PAGE)],
320
+ terminal: true,
321
+ });
322
+ }
323
+
324
+ // TLS policies are built up additively; at most two reach Caddy.
325
+ //
326
+ // 1. Realm DNS slug policy (specific subjects via acme-dns) - present
327
+ // iff `acmeDns` is configured. Covers `*.{workerBaseDomain}` only
328
+ // - the same shape on every worker, including the one that runs
329
+ // Home (whose dashboard lives at `home-{nanoid}.{workerBaseDomain}`,
330
+ // one label under that wildcard). No bare-slug wildcard, so the
331
+ // dashboard URL is not derivable from the slug alone.
332
+ // 2. Catch-all for everything outside the slug wildcard, and there is
333
+ // always exactly one. BYO public providers (cloudflare/hetzner) take it
334
+ // if configured - Caddy races the issuers and wrong-zone failures bail
335
+ // locally before LE is signaled. Otherwise the internal CA, so an
336
+ // uncovered domain gets a self-signed cert (browser warning) rather
337
+ // than a hard TLS failure or a doomed public-ACME attempt.
338
+ const policies: CaddyTlsPolicy[] = [];
339
+ // Names Caddy proactively obtains and serves certs for. The wildcard (via
340
+ // acme-dns) covers the dashboard host + every app subdomain; listing it in
341
+ // `certificates.automate` below is what stops Caddy from instead managing a
342
+ // per-host cert per route, whose DNS-01 challenge the shim never writes.
343
+ const automate: string[] = [];
344
+ if (acmeDns != null && workerId) {
345
+ const wildcard = `*.${workerId}.${acmeDns.baseDomain}`;
346
+ automate.push(wildcard);
347
+ policies.push({ subjects: [wildcard], issuers: [buildAcmeDnsIssuer(workerId, acmeDns)] });
348
+ }
349
+ if (publicProviders.length > 0) {
350
+ policies.push({ issuers: publicProviders.map(buildPublicIssuer) });
351
+ } else {
352
+ policies.push({ issuers: [{ module: "internal" }] });
353
+ }
354
+
355
+ // Self-register the worker's wildcard A. Same guard as the acme-dns TLS
356
+ // policy above (a slug base to own + a worker label): caddy-dynamicdns drives
357
+ // the `realm` provider, which POSTs `{subdomain: workerId}` to the shim's /a,
358
+ // and the shim points `*.{workerId}.{baseDomain}` at the request source IP.
359
+ // The `simple_http` ip_source is the shim's /ip echo, so the IP the tool
360
+ // detects == the IP /a records.
361
+ const dynamicDns: CaddyDynamicDns | undefined =
362
+ acmeDns != null && workerId
363
+ ? {
364
+ domains: { [acmeDns.baseDomain]: [workerId] },
365
+ dns_provider: {
366
+ name: "realm",
367
+ server_url: acmeDns.serverUrl,
368
+ username: acmeDns.username,
369
+ api_key: acmeDns.apiKey,
370
+ },
371
+ ip_sources: [{ source: "simple_http", endpoints: [`${acmeDns.serverUrl}/ip`] }],
372
+ check_interval: "5m",
373
+ versions: { ipv4: true, ipv6: false },
374
+ }
375
+ : undefined;
376
+
377
+ return {
378
+ admin: { listen: "127.0.0.1:2019" },
379
+ apps: {
380
+ http: {
381
+ servers: {
382
+ main: {
383
+ listen: [":443", ":80"],
384
+ routes,
385
+ // With a CDN declared in front, resolve `{client_ip}`
386
+ // right-to-left (strict) past its edge addresses from
387
+ // X-Forwarded-For. Omitted entirely otherwise: Caddy then ignores
388
+ // inbound XFF and uses the direct peer, so a client can't spoof
389
+ // its address when nothing is in front. The module fails soft - an
390
+ // unreachable provider yields no ranges, degrading to that same
391
+ // direct-peer behaviour rather than trusting anything.
392
+ ...(cdnProviders.length > 0 && {
393
+ trusted_proxies: { source: "cdn" as const, providers: [...cdnProviders] },
394
+ trusted_proxies_strict: 1 as const,
395
+ client_ip_headers: ["X-Forwarded-For"],
396
+ }),
397
+ ...(workerId &&
398
+ baseDomain && {
399
+ errors: {
400
+ // Ordered pair, splitting on which dial failed. The marker is
401
+ // set only once Home has answered and approved, so its absence
402
+ // means Home itself was unreachable. Every app route under
403
+ // this wildcard carries a forward_auth hop (checkPublicDomains
404
+ // refuses a granted publicDomain under baseDomain at eval
405
+ // time), while the dashboard route has none and proxies
406
+ // straight to Home, so it falls to the second page.
407
+ //
408
+ // The first page is true on every box: the marker proves Home
409
+ // answered, so the dial that failed was the app's. The second
410
+ // names Home as the thing that is down, a claim only the box
411
+ // running it can make - the same gate as the dashboard route.
412
+ // A worker-only box leaves an unmarked 502 to Caddy's default.
413
+ routes: [
414
+ buildFallbackRoute(
415
+ workerId,
416
+ baseDomain,
417
+ ` && {http.vars.${AUTH_OK_VAR}} == "1"`,
418
+ DEPLOYING_PAGE,
419
+ ),
420
+ ...(dashboardHost !== null
421
+ ? [buildFallbackRoute(workerId, baseDomain, "", HOME_UNREACHABLE_PAGE)]
422
+ : []),
423
+ ],
424
+ },
425
+ }),
426
+ },
427
+ },
428
+ },
429
+ tls: {
430
+ ...(automate.length > 0 && { certificates: { automate } }),
431
+ automation: { policies },
432
+ },
433
+ ...(dynamicDns && { dynamic_dns: dynamicDns }),
434
+ },
435
+ };
436
+ }
437
+
438
+ // acme-dns creds are inlined into the JSON, NOT passed as `{env.X}`
439
+ // placeholders: the caddy-dns/acmedns module reads its libdns struct fields
440
+ // verbatim and does not run Caddy's placeholder replacer, so an `{env.X}`
441
+ // value reaches the HTTP client as a literal string. These creds are
442
+ // Home-provisioned plain strings (not user secret refs), and the config file
443
+ // lives inside the container, no more exposed than the env would have been.
444
+ //
445
+ // `subdomain` is the label the caller owns under acme-dns: this worker's
446
+ // slug-relative label. The PEM in `acme.caRootPem` is materialized at
447
+ // ACME_CA_ROOT_PATH.
448
+ function buildAcmeDnsIssuer(subdomain: string, acme: AcmeIssuerInput): CaddyIssuer {
449
+ return {
450
+ module: "acme",
451
+ // Default ACME endpoint (Let's Encrypt prod) unless overridden to a
452
+ // staging endpoint or a test CA (Pebble) in the e2e.
453
+ ...(acme.ca && { ca: acme.ca }),
454
+ ...(acme.caRootPem && { trusted_roots_pem_files: [ACME_CA_ROOT_PATH] }),
455
+ challenges: {
456
+ dns: {
457
+ provider: {
458
+ name: "acmedns",
459
+ server_url: acme.serverUrl,
460
+ username: acme.username,
461
+ password: acme.apiKey,
462
+ // Static at config-gen time: the worker's own label under acme-dns.
463
+ subdomain,
464
+ },
465
+ // Wait for the Hetzner write to propagate before asking LE to
466
+ // validate. Too short and LE's multi-perspective validation races
467
+ // propagation: it queries an anycast PoP still serving the previous
468
+ // challenge value and fails with "Incorrect TXT record". Convergence
469
+ // measures ~30s from one vantage point and LE checks worldwide, so the
470
+ // delay sits above that with margin. Polling can't substitute — Caddy
471
+ // sees only its own resolver, never the stale PoP LE hits.
472
+ propagation_delay: "60s",
473
+ },
474
+ },
475
+ };
476
+ }
477
+
478
+ function buildPublicIssuer(provider: PublicProviderInput): CaddyIssuer {
479
+ const dnsProvider: CaddyProvider =
480
+ provider.name === "cloudflare"
481
+ ? { name: "cloudflare", api_token: provider.apiToken }
482
+ : { name: "hetzner", api_token: provider.apiToken };
483
+ return { module: "acme", challenges: { dns: { provider: dnsProvider } } };
484
+ }
485
+
486
+ // Every upstream proxy overwrites X-Forwarded-For + X-Real-IP with the single
487
+ // resolved client IP (`clientIp`, computed once in buildCaddyConfig), so
488
+ // upstreams always see one trustworthy address - never a spoofable chain.
489
+ // `set` overwrites, so this also strips whatever XFF chain the CDN appended.
490
+ function proxy(
491
+ upstream: string,
492
+ clientIp: string,
493
+ response?: CaddyHeaderRewrite,
494
+ ): Extract<CaddyHandler, { handler: "reverse_proxy" }> {
495
+ return {
496
+ handler: "reverse_proxy",
497
+ upstreams: [{ dial: upstream }],
498
+ headers: {
499
+ request: {
500
+ set: {
501
+ "X-Forwarded-For": [clientIp],
502
+ "X-Real-IP": [clientIp],
503
+ },
504
+ },
505
+ ...(response ? { response } : {}),
506
+ },
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Proxy to an app container.
512
+ *
513
+ * Blanks any Home-session (`realm-home.*`) `Set-Cookie` the app emits, so an
514
+ * app can neither read the Home session (the inbound strip in
515
+ * `buildAuthRoute`) nor write one (here). See HOME_SET_COOKIE_REGEX for why a
516
+ * subdomain can otherwise overwrite the operator's session outright. Blanking
517
+ * rather than deleting the whole `Set-Cookie` field keeps the app's own
518
+ * cookies, exactly as the inbound strip keeps the app's own request cookies; a
519
+ * `Set-Cookie` with no `=` is discarded by the browser.
520
+ */
521
+ function appProxy(upstream: string, clientIp: string): CaddyHandler {
522
+ return proxy(upstream, clientIp, {
523
+ replace: { "Set-Cookie": [{ search_regexp: HOME_SET_COOKIE_REGEX, replace: "" }] },
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Proxy to Home itself (the dashboard host).
529
+ *
530
+ * Deliberately does NOT strip session cookies: this is the hop that mints them.
531
+ * Kept as its own function so the exemption is a named decision rather than a
532
+ * flag someone can pass wrongly at a fourth call site.
533
+ *
534
+ * The one route with `stream_close_delay`: remote workers' uplinks are upgraded
535
+ * streams on this host, and a deploy on Home's box reloads Caddy. App routes go
536
+ * without - they are ordinary HTTP, and a deploy is allowed to bounce them.
537
+ */
538
+ function homeProxy(upstream: string, clientIp: string): CaddyHandler {
539
+ return { ...proxy(upstream, clientIp), stream_close_delay: STREAM_CLOSE_DELAY };
540
+ }
541
+
542
+ function pushRoutes(
543
+ routes: CaddyRoute[],
544
+ domains: string[],
545
+ router: IngressRouter,
546
+ upstream: string,
547
+ trustedHeaderKeys: string[],
548
+ clientIp: string,
549
+ ): void {
550
+ if (domains.length === 0) return;
551
+ if (domains.length === 1 || router.domainMode === "all") {
552
+ routes.push(buildPrimaryRoute(domains, router, upstream, trustedHeaderKeys, clientIp));
553
+ return;
554
+ }
555
+ // Redirect mode: primary serves; the rest 301 to it.
556
+ const [primary, ...rest] = domains;
557
+ if (!primary) return;
558
+ routes.push(buildPrimaryRoute([primary], router, upstream, trustedHeaderKeys, clientIp));
559
+ routes.push(buildRedirectRoute(rest, primary, router.pathPrefix));
560
+ }
561
+
562
+ function buildPrimaryRoute(
563
+ domains: string[],
564
+ router: IngressRouter,
565
+ upstream: string,
566
+ trustedHeaderKeys: string[],
567
+ clientIp: string,
568
+ ): CaddyRoute {
569
+ const handle: CaddyHandler[] = [];
570
+ // Reject early before doing any other work.
571
+ const rateLimitHandler = buildRateLimitHandler(router.rateLimit, clientIp);
572
+ if (rateLimitHandler) handle.push(rateLimitHandler);
573
+ // This route has no forward_auth, so no verified identity reaches it.
574
+ // Strip the platform namespace and every broker header the client may have
575
+ // set — otherwise the public route would be a trivial identity-spoof bypass.
576
+ handle.push({
577
+ handler: "headers",
578
+ request: { delete: [REALM_HEADER_WILDCARD, ...trustedHeaderKeys] },
579
+ });
580
+ handle.push(...buildContentHandlers(router));
581
+ handle.push(appProxy(upstream, clientIp));
582
+ return { match: [buildMatcher(domains, router.pathPrefix)], handle, terminal: true };
583
+ }
584
+
585
+ function buildAuthRoute(
586
+ host: string,
587
+ router: IngressRouter,
588
+ upstream: string,
589
+ trustedHeaderKeys: string[],
590
+ clientIp: string,
591
+ ): CaddyRoute {
592
+ const handle: CaddyHandler[] = [];
593
+ // Reject early before doing any other work — including the verify hop.
594
+ const rateLimitHandler = buildRateLimitHandler(router.rateLimit, clientIp);
595
+ if (rateLimitHandler) handle.push(rateLimitHandler);
596
+ // Strip the client's own copy of the platform namespace and every broker
597
+ // header BEFORE the verify hop, so a logged-in user can't smuggle in
598
+ // another user's identity — nor a header Home stamps on its other door.
599
+ // The verified values are re-added by forward_auth's copy_headers below
600
+ // (on 2xx), so this delete must run first.
601
+ handle.push({
602
+ handler: "headers",
603
+ request: { delete: [REALM_HEADER_WILDCARD, ...trustedHeaderKeys] },
604
+ });
605
+ // Gate on a logged-in Home session. On 2xx the chain continues to the app
606
+ // proxy; on a bounce Caddy relays Home's 302/401 to the client.
607
+ handle.push(...buildForwardAuthHandlers(VERIFY_UPSTREAM, VERIFY_PATH, trustedHeaderKeys));
608
+ handle.push(...buildContentHandlers(router));
609
+ // Strip the Home session cookie on the app hop ONLY — the verify sub-request
610
+ // above needed it; a downstream app seeing it is a Home-session-takeover
611
+ // vector. Snip just the better-auth cookies via regex so the app keeps its
612
+ // OWN cookies; deleting the whole `Cookie` header would break any app that
613
+ // sets cookies on its subdomain. Ordering: after forward_auth, before the
614
+ // app proxy.
615
+ handle.push({
616
+ handler: "headers",
617
+ request: { replace: { Cookie: [{ search_regexp: HOME_COOKIE_REGEX, replace: "" }] } },
618
+ });
619
+ handle.push(appProxy(upstream, clientIp));
620
+ return { match: [buildMatcher([host], router.pathPrefix)], handle, terminal: true };
621
+ }
622
+
623
+ // encode + response security headers + request-body limit — the middle of
624
+ // the handler chain, shared by the public and authenticated route variants.
625
+ function buildContentHandlers(router: IngressRouter): CaddyHandler[] {
626
+ const handle: CaddyHandler[] = [];
627
+ if (router.compress) {
628
+ handle.push({ handler: "encode", encodings: { gzip: {} }, prefer: ["gzip"] });
629
+ }
630
+ const headerHandler = buildHeaderHandler(router.headers);
631
+ if (headerHandler) handle.push(headerHandler);
632
+ if (router.maxRequestSizeMb != null) {
633
+ handle.push({
634
+ handler: "request_body",
635
+ max_size: router.maxRequestSizeMb * 1024 * 1024,
636
+ });
637
+ }
638
+ return handle;
639
+ }
640
+
641
+ // The forward_auth seam — the single swappable unit, so a different access
642
+ // model only changes what Home returns. One reverse_proxy handler that
643
+ // sub-requests the Home verify endpoint (forwarding the Cookie that the default
644
+ // reverse_proxy already adds, plus the original host, method and uri). On any
645
+ // non-2xx Caddy relays Home's response (302 → login, or 401) to the client
646
+ // verbatim. On 2xx the handle_response routes run before the chain falls
647
+ // through to the app proxy: the first sets the auth-ok marker, and when the
648
+ // app brokers headers (`getTrustedHeaders`) one guarded route per header lifts
649
+ // the verified value from the verify response
650
+ // (`{http.reverse_proxy.header.<name>}`, the copy_headers placeholder) onto the
651
+ // upstream request.
652
+ //
653
+ // The guard is the point. Caddy's `headers` handler leaves an unresolved
654
+ // placeholder as its literal text, and an anonymous admit is a 2xx with no
655
+ // identity headers - so an unguarded set would hand the container the string
656
+ // `{http.reverse_proxy.header.X-Forwarded-User}` as the user. The `vars`
657
+ // matcher resolves an absent placeholder to "", so `not vars {p} == ""` runs
658
+ // the set only when Home actually sent the header. The client's own copy was
659
+ // deleted before the hop, so on an anonymous admit the app sees no header at
660
+ // all, which is what access-gate.ts promises.
661
+ function buildForwardAuthHandlers(
662
+ verifyUpstream: string,
663
+ verifyPath: string,
664
+ trustedHeaderKeys: string[],
665
+ ): CaddyHandler[] {
666
+ // Unconditional, and first: it must be set whether or not the app brokers
667
+ // headers, because the error routes below read it to decide which of the two
668
+ // dials on this route failed.
669
+ const onAuthed: Array<{
670
+ match?: Array<{ not: Array<{ vars: Record<string, string[]> }> }>;
671
+ handle: CaddyHandler[];
672
+ }> = [{ handle: [{ handler: "vars", [AUTH_OK_VAR]: "1" }] }];
673
+ for (const k of trustedHeaderKeys) {
674
+ const placeholder = `{http.reverse_proxy.header.${k}}`;
675
+ onAuthed.push({
676
+ match: [{ not: [{ vars: { [placeholder]: [""] } }] }],
677
+ handle: [{ handler: "headers", request: { set: { [k]: [placeholder] } } }],
678
+ });
679
+ }
680
+ return [
681
+ {
682
+ handler: "reverse_proxy",
683
+ rewrite: { method: "GET", uri: verifyPath },
684
+ headers: {
685
+ request: {
686
+ // Host is pinned, not forwarded. Home picks the anonymous gate and
687
+ // the brokered-header mapping from this header alone, and Caddy
688
+ // keeps a client-supplied value once server-level `trusted_proxies`
689
+ // is configured (a declared `cdn`) — which would let a caller borrow
690
+ // another app's gate. `set` is overwrite, so the route's host wins.
691
+ set: {
692
+ "X-Forwarded-Host": ["{http.request.host}"],
693
+ "X-Forwarded-Method": ["{http.request.method}"],
694
+ "X-Forwarded-Uri": ["{http.request.uri}"],
695
+ },
696
+ },
697
+ },
698
+ // `[2]` is Caddy's shorthand for the whole 2xx class.
699
+ handle_response: [{ match: { status_code: [2] }, routes: onAuthed }],
700
+ upstreams: [{ dial: verifyUpstream }],
701
+ },
702
+ ];
703
+ }
704
+
705
+ // caddy-ratelimit is sliding-window: no more than `max_events` in any
706
+ // `window`. The SDK speaks the same model — a bare number is requests/second.
707
+ function buildRateLimitHandler(
708
+ rateLimit: number | { requests: number; window: string } | null | undefined,
709
+ clientIp: string,
710
+ ): CaddyHandler | undefined {
711
+ if (rateLimit == null) return undefined;
712
+ const { max_events, window } =
713
+ typeof rateLimit === "number"
714
+ ? { max_events: rateLimit, window: "1s" }
715
+ : { max_events: rateLimit.requests, window: rateLimit.window };
716
+ return {
717
+ handler: "rate_limit",
718
+ rate_limits: {
719
+ // `clientIp` must be a placeholder Caddy actually expands — an
720
+ // unresolved literal would collapse every client into one bucket.
721
+ per_ip: { key: clientIp, window, max_events },
722
+ },
723
+ };
724
+ }
725
+
726
+ // The one response every fallback page ships as. Carries the default security
727
+ // headers because these routes bypass `buildContentHandlers`, and for a newly
728
+ // minted app subdomain this is often the first response a browser ever sees for
729
+ // that hostname — which is exactly the response that should set HSTS.
730
+ function holdingResponse(body: string): CaddyHandler {
731
+ return {
732
+ handler: "static_response",
733
+ status_code: 503,
734
+ headers: {
735
+ "Content-Type": ["text/html; charset=utf-8"],
736
+ "Retry-After": ["5"],
737
+ ...securityHeaderLists(),
738
+ },
739
+ body,
740
+ };
741
+ }
742
+
743
+ // One error-handler route: a page served for a failed dial on this worker's own
744
+ // wildcard. `extraExpr` is appended to the status narrowing to split the two
745
+ // causes; "" makes the route the catch-all of the pair, so it must come last.
746
+ function buildFallbackRoute(
747
+ workerId: string,
748
+ baseDomain: string,
749
+ extraExpr: string,
750
+ body: string,
751
+ ): CaddyErrorRoute {
752
+ return {
753
+ match: [
754
+ {
755
+ host: [`*.${workerId}.${baseDomain}`],
756
+ expression: `{http.error.status_code} in [502, 504]${extraExpr}`,
757
+ },
758
+ ],
759
+ handle: [holdingResponse(body)],
760
+ terminal: true,
761
+ };
762
+ }
763
+
764
+ function buildRedirectRoute(
765
+ domains: string[],
766
+ primary: string,
767
+ pathPrefix: string | undefined,
768
+ ): CaddyRoute {
769
+ return {
770
+ match: [buildMatcher(domains, pathPrefix)],
771
+ handle: [
772
+ {
773
+ handler: "static_response",
774
+ headers: { Location: [`https://${primary}{http.request.orig_uri}`] },
775
+ status_code: 301,
776
+ },
777
+ ],
778
+ terminal: true,
779
+ };
780
+ }
781
+
782
+ function buildMatcher(
783
+ domains: string[],
784
+ pathPrefix: string | undefined,
785
+ ): { host: string[]; path?: string[] } {
786
+ return { host: domains, ...(pathPrefix ? { path: [`${pathPrefix}*`] } : {}) };
787
+ }
788
+
789
+ // Sorted, and that is load-bearing rather than tidy. This function's output is
790
+ // order-sensitive in two places - the `delete` array, and where a non-default
791
+ // key lands in `set` - while `router.headers` is a `z.record` whose key order
792
+ // the wire does not preserve: `canonicalJsonStringify` sorts every key at every
793
+ // depth, so the record an author wrote reaches the renderer in lexicographic
794
+ // order regardless. Iterating insertion order would therefore make the rendered
795
+ // document depend on which side of the wire the input came from - identical to
796
+ // Caddy, but not to anything comparing bytes or diffing config revisions.
797
+ function buildHeaderHandler(
798
+ overrides: Record<string, string | null> | undefined,
799
+ ): CaddyHandler | undefined {
800
+ const set: Record<string, string> = { ...DEFAULT_SECURITY_HEADERS };
801
+ const del: string[] = [];
802
+ if (overrides) {
803
+ for (const [k, v] of Object.entries(overrides).sort(([a], [b]) => (a < b ? -1 : 1))) {
804
+ if (v === null) {
805
+ delete set[k];
806
+ del.push(k);
807
+ } else {
808
+ set[k] = v;
809
+ }
810
+ }
811
+ }
812
+ if (Object.keys(set).length === 0 && del.length === 0) return undefined;
813
+ const setArr: Record<string, string[]> = {};
814
+ for (const [k, v] of Object.entries(set)) setArr[k] = [v];
815
+ const response: { set?: Record<string, string[]>; delete?: string[] } = {};
816
+ if (Object.keys(setArr).length > 0) response.set = setArr;
817
+ if (del.length > 0) response.delete = del;
818
+ return { handler: "headers", response };
819
+ }