@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.
- package/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- 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
|
+
}
|