@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,523 @@
1
+ // The worker's client loop: one session at a time, dialled by the worker and
2
+ // never by Home. Everything the two say to each other rides it.
3
+ //
4
+ // The loop is deliberately dumb — dial, hello, announce, pull, keep alive,
5
+ // dial again — with exactly three ways to stop for good, each of them a thing
6
+ // an operator has to fix: a Home whose static key is not the pinned one, an
7
+ // enrollment token Home refuses, and a worker whose row is gone with no token
8
+ // to re-arm it. Everything else is a reconnect.
9
+ //
10
+ // A Home on another `PROTOCOL_VERSION` is not one of the three. The hello
11
+ // succeeded, so the session is real and Home can send `update` over it; the
12
+ // loop simply holds it without announcing, and this worker stays offline to
13
+ // everything else until the box has been moved.
14
+
15
+ import { log } from "@local/logger";
16
+ import type { Keypair } from "@yourrealm/noise";
17
+ import type { WorkerCapability } from "../permissions.ts";
18
+ import { readNodeVersion, shortNodeVersion } from "../worker-protocol/index.ts";
19
+ import {
20
+ type AppApplyResult,
21
+ BACKOFF_BASE_MS,
22
+ BACKOFF_CAP_MS,
23
+ callJson,
24
+ connectUplink,
25
+ HomeKeyMismatch,
26
+ MethodError,
27
+ type Params,
28
+ PING_INTERVAL_MS,
29
+ PONG_TIMEOUT_MS,
30
+ PROTOCOL_VERSION,
31
+ pullState,
32
+ REKEY_INTERVAL_MS,
33
+ REKEY_JITTER_MS,
34
+ type Result,
35
+ type RpcHandler,
36
+ type StatePull,
37
+ type UpdateStatus,
38
+ type UplinkSession,
39
+ } from "../worker-protocol/uplink/index.ts";
40
+ import type { Enrollment } from "./identity.ts";
41
+ import { currentPublicIp, setPublicIpEcho } from "./public-ip.ts";
42
+
43
+ /**
44
+ * Every uplink call rejects with this while there is no live session, rather
45
+ * than queueing behind a reconnect: the verify relay turns it into its 502 and
46
+ * the dashboard shows the rest as failures (`todo/ideas/uplink.md` § 4 RPC).
47
+ *
48
+ * `reason` is what the caller gets told instead of "the uplink to Home is
49
+ * down", for the case where that sentence would be a lie: a session held on a
50
+ * protocol mismatch is up and talking, and the 502 should say so.
51
+ */
52
+ export class WorkerOffline extends Error {
53
+ constructor(method: string, reason = "the uplink to Home is down") {
54
+ super(`${reason}; ${method} cannot be answered`);
55
+ this.name = "WorkerOffline";
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The three lines `worker.sh` greps this worker's container log for while it
61
+ * waits out an install: one for a session that opened, and the two failures an
62
+ * operator can act on. They live here, next to the `log` calls that emit them,
63
+ * so the installer reads the same strings the loop prints and changing one is a
64
+ * type error rather than an install that parks for four minutes.
65
+ */
66
+ export const UPLINK_LOG = {
67
+ open: "Uplink open as",
68
+ tokenRefused: "Home refused this enrollment token",
69
+ keyMismatch: "Uplink key mismatch",
70
+ } as const;
71
+
72
+ /** The cadences the loop runs on. Defaulted from the protocol's constants. */
73
+ export interface UplinkTiming {
74
+ pingIntervalMs: number;
75
+ pongTimeoutMs: number;
76
+ rekeyIntervalMs: number;
77
+ rekeyJitterMs: number;
78
+ backoffBaseMs: number;
79
+ backoffCapMs: number;
80
+ }
81
+
82
+ const DEFAULT_TIMING: UplinkTiming = {
83
+ pingIntervalMs: PING_INTERVAL_MS,
84
+ pongTimeoutMs: PONG_TIMEOUT_MS,
85
+ rekeyIntervalMs: REKEY_INTERVAL_MS,
86
+ rekeyJitterMs: REKEY_JITTER_MS,
87
+ backoffBaseMs: BACKOFF_BASE_MS,
88
+ backoffCapMs: BACKOFF_CAP_MS,
89
+ };
90
+
91
+ interface UplinkOptions {
92
+ /** `wss://{dashboardHost}/uplink`, or `ws://realm-home:3100/uplink`. */
93
+ url: string;
94
+ identity: Keypair;
95
+ /** The pin. A responder that answers with anything else is refused. */
96
+ homePublicKey: Uint8Array;
97
+ /** The OTP half of `REALM_TOKEN`, when there is one. */
98
+ otp: string | null;
99
+ /** Display name for a first enroll; the installer defaults it to the hostname. */
100
+ name: string | null;
101
+ /** What a previous boot enrolled as, or null. */
102
+ enrolled: Enrollment | null;
103
+ capabilities: () => Promise<WorkerCapability[]>;
104
+ /**
105
+ * What this box's ingress container is running, or null where it has none.
106
+ * Read on every announce for the same reason `capabilities` is: it is a fact
107
+ * about the box the worker can only observe, and a self-update moves it
108
+ * without the worker restarting.
109
+ */
110
+ ingressImage: () => Promise<{ imageRef: string } | null>;
111
+ /**
112
+ * What the box's updater-driven projects are doing, read on every announce
113
+ * for the same reason `ingressImage` is: the files move under a live
114
+ * session (the updater writes them), and `announce` is where Home resets its
115
+ * whole picture of this box.
116
+ */
117
+ updates: () => Promise<UpdateStatus[]>;
118
+ /** The Home → worker methods. */
119
+ handlers: Record<string, RpcHandler>;
120
+ /** The etag this worker is actually running, for `announce`. */
121
+ appliedEtag: () => string | null;
122
+ /** Runs once per connect, after the announce: connect is pull. */
123
+ onConnected: () => void;
124
+ /** Persists what `enroll` handed back. */
125
+ onEnrolled: (enrollment: Enrollment) => Promise<void>;
126
+ timing?: Partial<UplinkTiming>;
127
+ /** Test seam: the dial. */
128
+ connect?: typeof connectUplink;
129
+ }
130
+
131
+ export interface UplinkHandle {
132
+ /** True while a session is open and announced. */
133
+ readonly online: boolean;
134
+ /** Resolves with the reason once the loop has given up for good. */
135
+ readonly stopped: Promise<string>;
136
+ verify(params: Params<"verify">): Promise<Result<"verify">>;
137
+ apiAccess(params: Params<"apiAccess">): Promise<Result<"apiAccess">>;
138
+ pull(params: Params<"state.pull">): Promise<StatePull>;
139
+ applied(etag: string, apps: AppApplyResult[]): Promise<void>;
140
+ /** `update.status` for one project; rejects with `WorkerOffline` like the rest. */
141
+ updateStatus(status: UpdateStatus): Promise<void>;
142
+ /**
143
+ * Drop the live session so the loop redials. A no-op when none is open.
144
+ *
145
+ * Not `close()`, which stops the loop for good. This exists for the one class
146
+ * of change nothing else carries: a fact about the box that `announce`
147
+ * reports and that moves while the session is up. The box ingress is that
148
+ * fact - the updater sidecar replaces its container without restarting this
149
+ * process - and the announce only ever runs on connect, so without a redial
150
+ * Home holds the old image ref until the 24h rekey.
151
+ *
152
+ * A redial is the whole repair rather than half of it, because connect is
153
+ * pull: the fresh `announce` carries the moved fact AND `onConnected` re-runs
154
+ * the idempotent reconcilers, which is what re-attaches a replaced ingress to
155
+ * every routed app's network. It costs one sub-second gap (the backoff is
156
+ * re-armed from zero after a successful hello).
157
+ *
158
+ * A held session is dropped too: it is a session, and the redial is the only
159
+ * way one ever stops being held.
160
+ */
161
+ reconnect(reason: string): void;
162
+ /** Stops the loop and closes the session. Resolves once both are done. */
163
+ close(): Promise<void>;
164
+ }
165
+
166
+ /**
167
+ * What the announce decided: keep this session, hold it unannounced, redial,
168
+ * or stop for good.
169
+ */
170
+ type Announced = "open" | "held" | "retry" | { stop: string };
171
+
172
+ export function startUplink(opts: UplinkOptions): UplinkHandle {
173
+ const timing = { ...DEFAULT_TIMING, ...opts.timing };
174
+ const dial = opts.connect ?? connectUplink;
175
+
176
+ /** The announced session every helper rides. */
177
+ let session: UplinkSession | null = null;
178
+ /**
179
+ * A session whose hello Home answered on another `PROTOCOL_VERSION`. Kept
180
+ * open so Home can send `update` over it, and used for nothing else; `reason`
181
+ * is what every helper rejects with meanwhile.
182
+ */
183
+ let held: { live: UplinkSession; reason: string } | null = null;
184
+ /**
185
+ * Whatever the loop currently holds, announced or not, so `close()` can end
186
+ * it and `keepPinging` can tell "still mine" from "replaced".
187
+ */
188
+ let dialled: UplinkSession | null = null;
189
+ let enrolled = opts.enrolled;
190
+ let closing = false;
191
+ /** Wakes the backoff sleep early so `close()` never waits it out. */
192
+ let wake: (() => void) | null = null;
193
+
194
+ let settle: (reason: string) => void = () => {};
195
+ const stopped = new Promise<string>((resolve) => {
196
+ settle = resolve;
197
+ });
198
+
199
+ function requireSession(method: string): UplinkSession {
200
+ if (session === null) throw new WorkerOffline(method, held?.reason);
201
+ return session;
202
+ }
203
+
204
+ function sleep(ms: number): Promise<void> {
205
+ return new Promise((resolve) => {
206
+ const id = setTimeout(() => {
207
+ wake = null;
208
+ resolve();
209
+ }, ms);
210
+ wake = () => {
211
+ clearTimeout(id);
212
+ wake = null;
213
+ resolve();
214
+ };
215
+ });
216
+ }
217
+
218
+ // Full jitter, doubling from the base to the cap. `attempt` is reset by
219
+ // every session that announced, so a flapping Home is retried hard and a
220
+ // gone one is retried slowly.
221
+ function backoff(attempt: number): number {
222
+ const ceiling = Math.min(timing.backoffCapMs, timing.backoffBaseMs * 2 ** (attempt - 1));
223
+ return Math.random() * ceiling;
224
+ }
225
+
226
+ async function readCapabilities(): Promise<WorkerCapability[]> {
227
+ // A bad probe must not brick boot: the worker announces nothing rather
228
+ // than refusing to announce, and apps needing a host kind land nowhere
229
+ // until the operator fixes the probe and restarts.
230
+ try {
231
+ return await opts.capabilities();
232
+ } catch (err) {
233
+ log.warn(`capabilities probe threw (${message(err)}); announcing []`);
234
+ return [];
235
+ }
236
+ }
237
+
238
+ async function readIngressImage(): Promise<{ imageRef: string } | null> {
239
+ // Same reasoning as the capabilities probe, and the same answer: a docker
240
+ // socket that will not answer must cost this box its version line, not its
241
+ // session. Null is already the shape for "no ingress here", so a failure
242
+ // reads as one - which is why the log line is what tells them apart.
243
+ try {
244
+ return await opts.ingressImage();
245
+ } catch (err) {
246
+ log.warn(`ingress image lookup threw (${message(err)}); announcing none`);
247
+ return null;
248
+ }
249
+ }
250
+
251
+ async function readUpdates(): Promise<UpdateStatus[]> {
252
+ // Same again. `[]` is the shape for a box with no `etc/` tree, so a read
253
+ // that threw reads as one, and the log line is what tells them apart.
254
+ try {
255
+ return await opts.updates();
256
+ } catch (err) {
257
+ log.warn(`update status read threw (${message(err)}); announcing []`);
258
+ return [];
259
+ }
260
+ }
261
+
262
+ async function enroll(
263
+ live: UplinkSession,
264
+ capabilities: WorkerCapability[],
265
+ publicIp: string | undefined,
266
+ ): Promise<Announced> {
267
+ const otp = opts.otp;
268
+ if (otp === null) {
269
+ return {
270
+ stop:
271
+ "this worker has not enrolled and REALM_TOKEN is unset, so there is nothing to " +
272
+ "enroll with. Add a worker in the dashboard and restart this box with the token " +
273
+ "it hands you.",
274
+ };
275
+ }
276
+ try {
277
+ const answer = await callJson(live.rpc, "enroll", {
278
+ otp,
279
+ ...(opts.name === null ? {} : { name: opts.name }),
280
+ capabilities,
281
+ version: readNodeVersion(),
282
+ ingress: await readIngressImage(),
283
+ ...(publicIp === undefined ? {} : { publicIp }),
284
+ });
285
+ enrolled = { workerId: answer.workerId, slug: answer.slug };
286
+ await opts.onEnrolled(enrolled);
287
+ setPublicIpEcho(answer.publicIpUrl);
288
+ log(`${UPLINK_LOG.open} ${answer.slug} (enrolled as ${answer.workerId})`);
289
+ return "open";
290
+ } catch (err) {
291
+ // A burnt, expired or wrong OTP is not retried: every retry would be
292
+ // the same refusal, and the fix is a fresh token from the dashboard.
293
+ if (err instanceof MethodError && (err.status === 404 || err.status === 410)) {
294
+ return { stop: `${UPLINK_LOG.tokenRefused} (${err.status}): ${err.body}` };
295
+ }
296
+ log.warn(`enroll failed (${message(err)})`);
297
+ return "retry";
298
+ }
299
+ }
300
+
301
+ async function announce(live: UplinkSession): Promise<Announced> {
302
+ const capabilities = await readCapabilities();
303
+ const publicIp = (await currentPublicIp()) ?? undefined;
304
+ if (enrolled === null) return await enroll(live, capabilities, publicIp);
305
+
306
+ const version = readNodeVersion();
307
+ let answer: Result<"hello">;
308
+ try {
309
+ answer = await callJson(live.rpc, "hello", { protocol: PROTOCOL_VERSION, version });
310
+ } catch (err) {
311
+ if (err instanceof MethodError && err.status === 404) {
312
+ // The row is gone. A token in the environment is how an operator
313
+ // re-arms a revoked box: edit the .env, restart, enroll afresh.
314
+ if (opts.otp === null) {
315
+ return {
316
+ stop:
317
+ "Home has no worker with this box's key. It was removed, or this box was " +
318
+ "restored from another realm's backup. Add a worker in the dashboard and " +
319
+ "restart this box with the token it hands you.",
320
+ };
321
+ }
322
+ log.warn("Home does not know this worker; enrolling with the token in REALM_TOKEN");
323
+ return await enroll(live, capabilities, publicIp);
324
+ }
325
+ log.warn(`hello failed (${message(err)})`);
326
+ return "retry";
327
+ }
328
+ setPublicIpEcho(answer.publicIpUrl);
329
+
330
+ if (answer.protocol !== PROTOCOL_VERSION) {
331
+ // Nothing this worker says past the door would be understood, so it says
332
+ // nothing. The session stays up because it is the only thing that can
333
+ // carry the `update` that fixes this.
334
+ log.warn(
335
+ `Home speaks uplink protocol ${answer.protocol} and this worker speaks ` +
336
+ `${PROTOCOL_VERSION}; holding the session and waiting for Home to update this box`,
337
+ );
338
+ held = {
339
+ live,
340
+ reason:
341
+ `Home speaks uplink protocol ${answer.protocol} and this worker speaks ` +
342
+ `${PROTOCOL_VERSION}; waiting to be updated`,
343
+ };
344
+ return "held";
345
+ }
346
+
347
+ try {
348
+ await callJson(live.rpc, "announce", {
349
+ capabilities,
350
+ ingress: await readIngressImage(),
351
+ appliedEtag: opts.appliedEtag(),
352
+ updates: await readUpdates(),
353
+ ...(publicIp === undefined ? {} : { publicIp }),
354
+ });
355
+ } catch (err) {
356
+ log.warn(`announce failed (${message(err)})`);
357
+ return "retry";
358
+ }
359
+ const label = shortNodeVersion(version);
360
+ log(`${UPLINK_LOG.open} ${answer.slug}${label === null ? "" : ` (version ${label})`}`);
361
+ return "open";
362
+ }
363
+
364
+ /**
365
+ * Pings on the cadence and closes the session when a pong is late. Keyed on
366
+ * the session the loop holds rather than on the announced one, because a held
367
+ * session has to stay alive to receive its `update`.
368
+ */
369
+ function keepPinging(live: UplinkSession): void {
370
+ void (async () => {
371
+ // One continuation on `closed`, not one per tick: the wait below is cut
372
+ // short through `wakeTick` so teardown never sits out a whole interval.
373
+ let ended = false;
374
+ let wakeTick: (() => void) | null = null;
375
+ void live.closed.then(() => {
376
+ ended = true;
377
+ wakeTick?.();
378
+ });
379
+ while (dialled === live && !closing && !ended) {
380
+ await new Promise<void>((resolve) => {
381
+ const id = setTimeout(resolve, timing.pingIntervalMs);
382
+ wakeTick = () => {
383
+ clearTimeout(id);
384
+ resolve();
385
+ };
386
+ });
387
+ wakeTick = null;
388
+ if (dialled !== live || closing || ended) return;
389
+ try {
390
+ await withDeadline(live.ping(), timing.pongTimeoutMs);
391
+ } catch {
392
+ live.close(`no pong inside ${timing.pongTimeoutMs}ms`);
393
+ return;
394
+ }
395
+ }
396
+ })();
397
+ }
398
+
399
+ async function run(): Promise<string> {
400
+ let attempt = 0;
401
+ while (!closing) {
402
+ let live: UplinkSession;
403
+ try {
404
+ live = await dial({
405
+ url: opts.url,
406
+ identity: opts.identity,
407
+ homePublicKey: opts.homePublicKey,
408
+ handlers: opts.handlers,
409
+ });
410
+ } catch (err) {
411
+ if (err instanceof HomeKeyMismatch) {
412
+ log.error(
413
+ `${UPLINK_LOG.keyMismatch}: ${opts.url} answered with ${err.actual}, ` +
414
+ `not the pinned ${err.expected}. Refusing to speak to it and not retrying; ` +
415
+ `this worker's apps keep running. Either something is impersonating Home, ` +
416
+ `or this box holds a stale pin.`,
417
+ );
418
+ return "Home's static key is not the pinned one";
419
+ }
420
+ attempt++;
421
+ const delay = backoff(attempt);
422
+ log.warn(
423
+ `Uplink to ${opts.url} failed (${message(err)}); retrying in ${Math.round(delay)}ms`,
424
+ );
425
+ await sleep(delay);
426
+ continue;
427
+ }
428
+ dialled = live;
429
+
430
+ const outcome = await announce(live);
431
+ if (outcome !== "open" && outcome !== "held") {
432
+ live.close(outcome === "retry" ? "announce failed" : "stopping");
433
+ await live.closed;
434
+ if (outcome !== "retry") {
435
+ log.error(outcome.stop);
436
+ return outcome.stop;
437
+ }
438
+ attempt++;
439
+ await sleep(backoff(attempt));
440
+ continue;
441
+ }
442
+
443
+ // The hello landed, so the next dial starts at full speed whether or not
444
+ // the announce followed it.
445
+ attempt = 0;
446
+ if (outcome === "open") session = live;
447
+ keepPinging(live);
448
+ // Rekey by reconnecting, so no session holds one key for a day.
449
+ const rekeyAt = timing.rekeyIntervalMs + (Math.random() * 2 - 1) * timing.rekeyJitterMs;
450
+ const rekey = setTimeout(() => live.close("rekey"), rekeyAt);
451
+ // Connect is pull: the reconcile Home dropped while this worker was
452
+ // down is subsumed by the one every connect runs. A held session pulls
453
+ // nothing — it does not know what Home would send it.
454
+ if (outcome === "open") opts.onConnected();
455
+
456
+ const reason = await live.closed;
457
+ clearTimeout(rekey);
458
+ session = null;
459
+ held = null;
460
+ dialled = null;
461
+ if (closing) break;
462
+ attempt++;
463
+ const delay = backoff(attempt);
464
+ log.warn(`Uplink closed (${reason}); reconnecting in ${Math.round(delay)}ms`);
465
+ await sleep(delay);
466
+ }
467
+ return "the worker stopped the uplink";
468
+ }
469
+
470
+ const loop = run().then((reason) => {
471
+ session = null;
472
+ held = null;
473
+ dialled = null;
474
+ settle(reason);
475
+ return reason;
476
+ });
477
+
478
+ return {
479
+ get online() {
480
+ return session !== null;
481
+ },
482
+ stopped,
483
+ verify: async (params) => await callJson(requireSession("verify").rpc, "verify", params),
484
+ apiAccess: async (params) =>
485
+ await callJson(requireSession("apiAccess").rpc, "apiAccess", params),
486
+ pull: async (params) => await pullState(requireSession("state.pull").rpc, params),
487
+ applied: async (etag, apps) => {
488
+ await callJson(requireSession("state.applied").rpc, "state.applied", { etag, apps });
489
+ },
490
+ updateStatus: async (status) => {
491
+ await callJson(requireSession("update.status").rpc, "update.status", status);
492
+ },
493
+ reconnect: (reason) => {
494
+ // Only a session past the door is worth dropping. Mid-dial there is
495
+ // nothing to replace, and the announce the loop is about to run already
496
+ // carries whatever moved.
497
+ (session ?? held?.live)?.close(reason);
498
+ },
499
+ close: async () => {
500
+ closing = true;
501
+ wake?.();
502
+ dialled?.close("the worker is stopping");
503
+ await loop;
504
+ },
505
+ };
506
+ }
507
+
508
+ async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
509
+ promise.catch(() => {});
510
+ let timer: ReturnType<typeof setTimeout> | undefined;
511
+ const expiry = new Promise<never>((_, reject) => {
512
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
513
+ });
514
+ try {
515
+ return await Promise.race([promise, expiry]);
516
+ } finally {
517
+ if (timer !== undefined) clearTimeout(timer);
518
+ }
519
+ }
520
+
521
+ function message(err: unknown): string {
522
+ return err instanceof Error ? err.message : String(err);
523
+ }
@@ -0,0 +1,34 @@
1
+ import type { WorkerState } from "../worker-protocol/index.ts";
2
+
3
+ /**
4
+ * Defense-in-depth check on the deserialized wire payload before the
5
+ * renderer runs. Home already validates its inputs end-to-end, but a
6
+ * mid-flight tarball corruption or a buggy Home commit shouldn't
7
+ * surface as a confusing renderer crash on the worker.
8
+ *
9
+ * Today: every depends_on edge points at an app+service that actually
10
+ * exists in the same payload.
11
+ */
12
+ export function validateWorkerState(state: WorkerState): void {
13
+ const services = new Map<string, Set<string>>();
14
+ for (const app of state.apps) {
15
+ services.set(app.appId, new Set(Object.keys(app.services)));
16
+ }
17
+
18
+ for (const app of state.apps) {
19
+ for (const [serviceName, edges] of Object.entries(app.dependsOn)) {
20
+ if (!services.get(app.appId)?.has(serviceName)) {
21
+ throw new Error(`dependsOn references missing service: ${app.appId}/${serviceName}`);
22
+ }
23
+ for (const edge of edges) {
24
+ const providerSvcs = services.get(edge.providerAppId);
25
+ if (!providerSvcs || !providerSvcs.has(edge.providerServiceName)) {
26
+ throw new Error(
27
+ `dependsOn edge in ${app.appId}/${serviceName} ` +
28
+ `points at missing producer ${edge.providerAppId}/${edge.providerServiceName}`,
29
+ );
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,102 @@
1
+ // Worker-side ForwardAuth relay. The ingress gates every authenticated app
2
+ // subdomain with a sub-request to Home's `/api/access/verify`, but the ingress
3
+ // holds no uplink identity and must not: the worker is the one process on the
4
+ // box that does. So the ingress dials `realm-worker:6021` by container name on
5
+ // the management bridge and this listener opens a `verify` stream to Home.
6
+ //
7
+ // The neighbours are whoever the bind lets in. Told a `hostname` - the worker's
8
+ // own address on management-net, which is what platform/worker resolves and
9
+ // passes - that is the ingress and nothing else. Told none, the listener is on
10
+ // every interface, which includes each UI app's `realm_<appId>` bridge, because
11
+ // the worker joins those for the surface proxy; every such app can then dial
12
+ // it.
13
+ //
14
+ // Either way only the single verify path is relayed, and that bound is
15
+ // load-bearing: it is the whole of what a neighbour may ask Home through this
16
+ // worker's session. A raw splice would hand the ingress - and anything else
17
+ // that reaches this port - the worker's own credential.
18
+
19
+ import { log } from "@local/logger";
20
+ import type { Params, Result } from "../worker-protocol/uplink/index.ts";
21
+
22
+ const VERIFY_PATH = "/api/access/verify";
23
+
24
+ // A verify is one session lookup — anything slow is Home being down, and
25
+ // the client is blocked on this hop, so fail fast rather than pile up.
26
+ const UPSTREAM_TIMEOUT_MS = 10_000;
27
+
28
+ interface VerifyRelayOptions {
29
+ port: number;
30
+ /**
31
+ * The one address to listen on: the worker's own on management-net. Omitted
32
+ * where the box could not resolve it, which binds every interface instead -
33
+ * see the warning below for why that is a fallback and not a default.
34
+ */
35
+ hostname?: string;
36
+ /** The `verify` method on the live session; rejects at once while it is down. */
37
+ verify: (params: Params<"verify">) => Promise<Result<"verify">>;
38
+ }
39
+
40
+ export function startVerifyRelay(opts: VerifyRelayOptions): Deno.HttpServer {
41
+ if (opts.hostname === undefined) {
42
+ // Not fatal: a worker off management-net has no reachable relay whichever
43
+ // way this binds, and refusing to start would hide that behind a second
44
+ // failure. But say what the wide bind costs.
45
+ log.warn(
46
+ `verify relay: no management-net address, binding every interface - every UI app whose bridge this worker joined can reach :${opts.port}`,
47
+ );
48
+ }
49
+ return Deno.serve({ hostname: opts.hostname ?? "0.0.0.0", port: opts.port }, async (req) => {
50
+ // Caddy's forward_auth rewrite sends every gate check as
51
+ // `GET /api/access/verify` (the original method rides
52
+ // X-Forwarded-Method), so anything else is not a gate check.
53
+ const url = new URL(req.url);
54
+ if (req.method !== "GET" || url.pathname !== VERIFY_PATH) {
55
+ return new Response("not found", { status: 404 });
56
+ }
57
+
58
+ let answer: Result<"verify">;
59
+ try {
60
+ answer = await withDeadline(
61
+ opts.verify({ search: url.search, headers: [...req.headers] }),
62
+ UPSTREAM_TIMEOUT_MS,
63
+ );
64
+ } catch (err) {
65
+ log.warn(`verify relay failed: ${(err as Error).message}`);
66
+ // Non-2xx is a deny, which Caddy relays to the client. Immediate while
67
+ // the uplink is down: waiting out a reconnect would hang every gated
68
+ // request on the box.
69
+ return new Response("verify upstream unreachable", { status: 502 });
70
+ }
71
+
72
+ // Status + headers verbatim: Caddy's copy_headers lifts the brokered
73
+ // identity headers off THIS response on 2xx, and a bounce needs its
74
+ // Location. Framing headers are dropped because Deno.serve re-frames the
75
+ // body it is handed.
76
+ const headers = new Headers();
77
+ for (const [name, value] of answer.headers) {
78
+ const key = name.toLowerCase();
79
+ if (key === "content-encoding" || key === "content-length") continue;
80
+ headers.append(name, value);
81
+ }
82
+ return new Response(decodeBody(answer.body), { status: answer.status, headers });
83
+ });
84
+ }
85
+
86
+ function decodeBody(body: string): Uint8Array<ArrayBuffer> | null {
87
+ if (body === "") return null;
88
+ return Uint8Array.fromBase64(body);
89
+ }
90
+
91
+ async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
92
+ promise.catch(() => {});
93
+ let timer: ReturnType<typeof setTimeout> | undefined;
94
+ const expiry = new Promise<never>((_, reject) => {
95
+ timer = setTimeout(() => reject(new Error(`verify timed out after ${ms}ms`)), ms);
96
+ });
97
+ try {
98
+ return await Promise.race([promise, expiry]);
99
+ } finally {
100
+ if (timer !== undefined) clearTimeout(timer);
101
+ }
102
+ }