@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,227 @@
|
|
|
1
|
+
import { log } from "@local/logger";
|
|
2
|
+
import type { WorkerCapability } from "../permissions.ts";
|
|
3
|
+
import type { SiloOrphan, WorkerState } from "../worker-protocol/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
type AppApplyResult,
|
|
6
|
+
jsonHandler,
|
|
7
|
+
parseEnrollmentToken,
|
|
8
|
+
type RpcHandler,
|
|
9
|
+
type UpdateProject,
|
|
10
|
+
type UpdateStatus,
|
|
11
|
+
} from "../worker-protocol/uplink/index.ts";
|
|
12
|
+
import type {
|
|
13
|
+
HomeProxyRequest,
|
|
14
|
+
HomeProxyResponse,
|
|
15
|
+
} from "../runtime/docker/home-proxy-server/protocol.ts";
|
|
16
|
+
import { loadIdentity, readEnrollment, resolveHomePublicKey, writeEnrollment } from "./identity.ts";
|
|
17
|
+
import { createReconcileQueue } from "./queue.ts";
|
|
18
|
+
import { declaredPortsIn, uiProxyHandler } from "./ui-proxy.ts";
|
|
19
|
+
import { startUplink, type UplinkHandle, WorkerOffline } from "./uplink.ts";
|
|
20
|
+
import { startVerifyRelay } from "./verify-relay.ts";
|
|
21
|
+
|
|
22
|
+
/** What a reconcile callback can reach on the live uplink. */
|
|
23
|
+
export interface ReconcileContext {
|
|
24
|
+
/** Relays one `home.apiAccess` call for `appId`; rejects while offline. */
|
|
25
|
+
apiAccess: (appId: string, req: HomeProxyRequest) => Promise<HomeProxyResponse>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StartWorkerOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Fires when a reconcile pulls a new ETag and the parsed state is ready to
|
|
31
|
+
* apply. Resolves with one result per app brought up; they ride
|
|
32
|
+
* `state.applied` beside the etag, so Home sees which apps actually came up.
|
|
33
|
+
*/
|
|
34
|
+
onNewState: (state: WorkerState, ctx: ReconcileContext) => Promise<AppApplyResult[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Fires on a 304 (unchanged state) with the last-known state. Mount only the
|
|
37
|
+
* cheap, idempotent healers here (broker-socket ensure) so a socket that
|
|
38
|
+
* vanished without a state change is rebuilt on the next reconcile trigger.
|
|
39
|
+
* Resolves with the results of any app it retried, empty when it only healed.
|
|
40
|
+
*/
|
|
41
|
+
onUnchanged?: (state: WorkerState, ctx: ReconcileContext) => Promise<AppApplyResult[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the host permission kinds this worker can satisfy. Called once per
|
|
44
|
+
* announce, so probes (filesystem, devices, etc.) re-run on every connect and
|
|
45
|
+
* reach the latest host state. Home uses the list to drop apps whose required
|
|
46
|
+
* permissions aren't matched by any deployed worker. Only host kinds — peer +
|
|
47
|
+
* remote are Home-resolved at eval and never flow over this wire.
|
|
48
|
+
*/
|
|
49
|
+
capabilities: () => Promise<WorkerCapability[]>;
|
|
50
|
+
/** Home → worker `siloOrphans`: `live` is every Silo id that still has a row. */
|
|
51
|
+
siloOrphans: (live: Set<string>) => Promise<SiloOrphan[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Home → worker `update`: pin one of this box's compose projects onto
|
|
54
|
+
* `imageRef` for its updater sidecar. The project is on the wire because the
|
|
55
|
+
* box has one sidecar and one etc/ tree - the worker is simply the only thing
|
|
56
|
+
* here holding a session, so it is what gets told about the ingress too.
|
|
57
|
+
*/
|
|
58
|
+
update: (imageRef: string, project: UpdateProject) => Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* What this box's ingress container is running, or null where it has none.
|
|
61
|
+
* Injected rather than read here, for the reason the whole `worker` layer is
|
|
62
|
+
* Docker-free: asking a container what image it was created from is an engine
|
|
63
|
+
* question, and platform/worker owns those.
|
|
64
|
+
*/
|
|
65
|
+
ingressImage: () => Promise<{ imageRef: string } | null>;
|
|
66
|
+
/**
|
|
67
|
+
* What each of this box's updater-driven compose projects is doing, for the
|
|
68
|
+
* announce. Injected for the same reason `ingressImage` is: which projects
|
|
69
|
+
* the box carries and where its `etc/` tree is mounted are platform/worker's
|
|
70
|
+
* facts, and this layer holds no more opinion about `REALM_ETC_DIR` than it
|
|
71
|
+
* already does.
|
|
72
|
+
*/
|
|
73
|
+
updates: () => Promise<UpdateStatus[]>;
|
|
74
|
+
/**
|
|
75
|
+
* The one address the verify relay listens on: this worker's own on
|
|
76
|
+
* management-net, where the ingress reaches it. Injected for the same reason
|
|
77
|
+
* as `ingressImage` - the address comes off a container inspect. Undefined
|
|
78
|
+
* where the box could not resolve it, which binds every interface instead
|
|
79
|
+
* (verify-relay.ts).
|
|
80
|
+
*/
|
|
81
|
+
verifyRelayHostname?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WorkerHandle {
|
|
85
|
+
/** Manually enqueue a reconcile (e.g. for tests or non-uplink triggers). */
|
|
86
|
+
trigger(): void;
|
|
87
|
+
/**
|
|
88
|
+
* Drop the session so the uplink redials, re-announcing and pulling on the
|
|
89
|
+
* way back in. For a fact the announce carries that changed under a live
|
|
90
|
+
* session - see `UplinkHandle.reconnect`. A no-op on a box with no uplink.
|
|
91
|
+
*/
|
|
92
|
+
reconnect(reason: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* Tell Home one project's `.update` / `.update.error` moved on disk. Rejects
|
|
95
|
+
* with `WorkerOffline` while there is no session; the next announce carries
|
|
96
|
+
* the whole set, so a caller can let that one go.
|
|
97
|
+
*/
|
|
98
|
+
updateStatus(status: UpdateStatus): Promise<void>;
|
|
99
|
+
/** Stops the uplink loop and closes the session. */
|
|
100
|
+
stop(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const WORKER_DIR = Deno.env.get("WORKER_DIR") ?? "/opt/realm/worker";
|
|
104
|
+
const ETC_DIR = Deno.env.get("REALM_ETC_DIR") ?? "/opt/realm/etc";
|
|
105
|
+
const VERIFY_RELAY_PORT = 6021;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Spine entry point. Lifecycle:
|
|
109
|
+
* 1. Bind the verify relay on :6021 — the worker's one listener, on the
|
|
110
|
+
* management-net address the caller resolved. It answers 502 until the
|
|
111
|
+
* uplink is up, which is the documented behaviour, so it goes first and
|
|
112
|
+
* never waits on Home.
|
|
113
|
+
* 2. Load this box's identity and resolve the pin, waiting for Home's key on
|
|
114
|
+
* a fresh co-located box.
|
|
115
|
+
* 3. Open the uplink and keep it open. Every RPC in either direction rides
|
|
116
|
+
* it, and the first connect pulls state.
|
|
117
|
+
*
|
|
118
|
+
* A worker whose Home is unreachable still runs its apps: nothing below
|
|
119
|
+
* blocks on a session, and the reconcilers run off the state on disk.
|
|
120
|
+
*/
|
|
121
|
+
export async function startWorker(opts: StartWorkerOptions): Promise<WorkerHandle> {
|
|
122
|
+
let uplink: UplinkHandle | null = null;
|
|
123
|
+
let lastState: WorkerState | null = null;
|
|
124
|
+
|
|
125
|
+
const context: ReconcileContext = {
|
|
126
|
+
apiAccess: async (appId, req) => {
|
|
127
|
+
if (uplink === null) throw new WorkerOffline("apiAccess");
|
|
128
|
+
return await uplink.apiAccess({ ...req, appId });
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const queue = createReconcileQueue({
|
|
133
|
+
workerDir: WORKER_DIR,
|
|
134
|
+
pull: async (params) => {
|
|
135
|
+
if (uplink === null) throw new WorkerOffline("state.pull");
|
|
136
|
+
return await uplink.pull(params);
|
|
137
|
+
},
|
|
138
|
+
applied: async (etag, apps) => {
|
|
139
|
+
if (uplink === null) throw new WorkerOffline("state.applied");
|
|
140
|
+
await uplink.applied(etag, apps);
|
|
141
|
+
},
|
|
142
|
+
onNewState: async (state) => {
|
|
143
|
+
lastState = state;
|
|
144
|
+
return await opts.onNewState(state, context);
|
|
145
|
+
},
|
|
146
|
+
...(opts.onUnchanged === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { onUnchanged: (state: WorkerState) => opts.onUnchanged!(state, context) }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// The ingress's ForwardAuth hop on every worker, co-located with Home or not
|
|
152
|
+
// — see verify-relay.ts for why the ingress can't ask Home itself.
|
|
153
|
+
startVerifyRelay({
|
|
154
|
+
port: VERIFY_RELAY_PORT,
|
|
155
|
+
...(opts.verifyRelayHostname === undefined ? {} : { hostname: opts.verifyRelayHostname }),
|
|
156
|
+
verify: async (params) => {
|
|
157
|
+
if (uplink === null) throw new WorkerOffline("verify");
|
|
158
|
+
return await uplink.verify(params);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const handlers: Record<string, RpcHandler> = {
|
|
163
|
+
reconcile: jsonHandler("reconcile", () => {
|
|
164
|
+
queue.trigger();
|
|
165
|
+
return { ok: true };
|
|
166
|
+
}),
|
|
167
|
+
siloOrphans: jsonHandler("siloOrphans", ({ live }) => opts.siloOrphans(new Set(live))),
|
|
168
|
+
update: jsonHandler("update", async ({ imageRef, project }) => {
|
|
169
|
+
await opts.update(imageRef, project);
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}),
|
|
172
|
+
uiProxy: uiProxyHandler((appId, service) =>
|
|
173
|
+
lastState === null ? undefined : declaredPortsIn(lastState, appId, service),
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const url = Deno.env.get("UPLINK_URL");
|
|
178
|
+
if (url === undefined || url === "") {
|
|
179
|
+
// Nothing to dial. The apps on this box keep running off the state on
|
|
180
|
+
// disk; a control plane is what is missing, and only a restart with the
|
|
181
|
+
// variable set brings one back.
|
|
182
|
+
log.error("UPLINK_URL is unset, so this worker has no way to reach Home");
|
|
183
|
+
return {
|
|
184
|
+
trigger: queue.trigger,
|
|
185
|
+
reconnect: () => {},
|
|
186
|
+
updateStatus: () => Promise.reject(new WorkerOffline("update.status")),
|
|
187
|
+
stop: () => Promise.resolve(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const token = Deno.env.get("REALM_TOKEN");
|
|
192
|
+
const parsed = token === undefined || token === "" ? null : parseEnrollmentToken(token);
|
|
193
|
+
if (token !== undefined && token !== "" && parsed === null) {
|
|
194
|
+
log.warn("REALM_TOKEN is not `{homePublicKey}.{otp}` or a bare OTP; ignoring it");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const identity = await loadIdentity(WORKER_DIR);
|
|
198
|
+
const homePublicKey = await resolveHomePublicKey({
|
|
199
|
+
workerDir: WORKER_DIR,
|
|
200
|
+
etcDir: ETC_DIR,
|
|
201
|
+
tokenKey: parsed?.homePublicKey ?? null,
|
|
202
|
+
});
|
|
203
|
+
const name = Deno.env.get("WORKER_NAME") ?? "";
|
|
204
|
+
|
|
205
|
+
uplink = startUplink({
|
|
206
|
+
url,
|
|
207
|
+
identity,
|
|
208
|
+
homePublicKey,
|
|
209
|
+
otp: parsed?.otp ?? null,
|
|
210
|
+
name: name === "" ? null : name,
|
|
211
|
+
enrolled: await readEnrollment(WORKER_DIR),
|
|
212
|
+
capabilities: opts.capabilities,
|
|
213
|
+
ingressImage: opts.ingressImage,
|
|
214
|
+
updates: opts.updates,
|
|
215
|
+
handlers,
|
|
216
|
+
appliedEtag: queue.appliedEtag,
|
|
217
|
+
onConnected: queue.trigger,
|
|
218
|
+
onEnrolled: (enrollment) => writeEnrollment(WORKER_DIR, enrollment),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
trigger: queue.trigger,
|
|
223
|
+
reconnect: (reason) => uplink!.reconnect(reason),
|
|
224
|
+
updateStatus: (status) => uplink!.updateStatus(status),
|
|
225
|
+
stop: () => uplink!.close(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Worker-side surface-call forwarder. Home authenticates the call and sends
|
|
2
|
+
// the request envelope down the session as `uiProxy`; we match the container
|
|
3
|
+
// by labels (`realm.app` + `realm.service`) and proxy to its IP on the per-app
|
|
4
|
+
// `realm_<appId>` bridge.
|
|
5
|
+
//
|
|
6
|
+
// Two bounds, not one. Reachability: `network-reconcile.ts` joins the worker
|
|
7
|
+
// to `realm_<appId>` only for ui-having apps, and Docker's
|
|
8
|
+
// DOCKER-ISOLATION-STAGE-2 chain DROPs FORWARD between user-defined bridges,
|
|
9
|
+
// so an app with no `ui:` block cannot be dialed even by IP. And the port:
|
|
10
|
+
// the worker's own wire state says which ports a service declares, and a call
|
|
11
|
+
// naming any other port of that service is refused before the container is
|
|
12
|
+
// even looked up (`todo/ideas/uplink.md` § The relays). Without it a Home that
|
|
13
|
+
// has been made to send an arbitrary port reaches every port of every
|
|
14
|
+
// realm-labelled container the worker can see — Caddy's admin `:2019` being
|
|
15
|
+
// the concrete danger.
|
|
16
|
+
|
|
17
|
+
import { LABELS, ode } from "@local/docker";
|
|
18
|
+
import { uiErrorBody, type UiErrorCode } from "../ui-error.ts";
|
|
19
|
+
import type { WorkerState } from "../worker-protocol/index.ts";
|
|
20
|
+
import {
|
|
21
|
+
jsonResponse,
|
|
22
|
+
methods,
|
|
23
|
+
type Params,
|
|
24
|
+
type RpcHandler,
|
|
25
|
+
type RpcResponse,
|
|
26
|
+
} from "../worker-protocol/uplink/index.ts";
|
|
27
|
+
|
|
28
|
+
type ProxyRequest = Params<"uiProxy">;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The ports the worker's current wire state attributes to one service.
|
|
32
|
+
*
|
|
33
|
+
* `undefined` means the wire carries no port for it, which today is the
|
|
34
|
+
* ordinary case for a ui-only service: the port a loader dials lives in the
|
|
35
|
+
* app's ui manifest, which is Home's and never crosses the wire. Those calls
|
|
36
|
+
* are let through; everything the wire CAN name is bounded to exactly it.
|
|
37
|
+
*/
|
|
38
|
+
export type DeclaredPorts = (appId: string, service: string) => Set<number> | undefined;
|
|
39
|
+
|
|
40
|
+
// Cap how long the worker waits on the container. A hung service would
|
|
41
|
+
// otherwise pile up inflight requests at three layers (iframe, Home,
|
|
42
|
+
// worker); the abort makes the pile bounded.
|
|
43
|
+
const UPSTREAM_TIMEOUT_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
// Proxy fetches must not reuse a pooled keep-alive connection: an idle
|
|
46
|
+
// socket to an app container can be dropped by the container's HTTP server
|
|
47
|
+
// or the Docker bridge without a RST reaching us, and Deno's global fetch
|
|
48
|
+
// would then hand the next request that dead half-open connection and block
|
|
49
|
+
// until UPSTREAM_TIMEOUT_MS. Created lazily because this file can be loaded
|
|
50
|
+
// into a QuickJS eval where the `Deno` global is absent.
|
|
51
|
+
let proxyHttpClient: Deno.HttpClient | undefined;
|
|
52
|
+
function getProxyHttpClient(): Deno.HttpClient {
|
|
53
|
+
return (proxyHttpClient ??= Deno.createHttpClient({ poolMaxIdlePerHost: 0 }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cap the response we'll buffer from the container. Mirrors the
|
|
57
|
+
// inbound `MAX_BODY_BYTES` in Home's secureHeaders. A misbehaving
|
|
58
|
+
// service returning gigabytes would otherwise OOM the worker (and
|
|
59
|
+
// then Home when this proxy passes it through).
|
|
60
|
+
const MAX_LOADER_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why a fetch failed. Deno throws `TypeError: fetch failed` and puts the
|
|
64
|
+
* reason (dns, refused, reset) on `cause`; the message alone says nothing.
|
|
65
|
+
*/
|
|
66
|
+
export function describeFetchError(err: unknown): string {
|
|
67
|
+
const e = err as { message?: string; cause?: { message?: string } };
|
|
68
|
+
const message = e.message ?? String(err);
|
|
69
|
+
return e.cause?.message ? `${message}: ${e.cause.message}` : message;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the `uiProxy` method answers with. The body is always JSON text. */
|
|
73
|
+
export interface UiProxyResponse {
|
|
74
|
+
status: number;
|
|
75
|
+
headers: Record<string, string>;
|
|
76
|
+
body: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The ports `state` declares for one service: every published `hostPorts`
|
|
81
|
+
* container port plus the routed one, if it is routed.
|
|
82
|
+
*/
|
|
83
|
+
export function declaredPortsIn(
|
|
84
|
+
state: WorkerState,
|
|
85
|
+
appId: string,
|
|
86
|
+
service: string,
|
|
87
|
+
): Set<number> | undefined {
|
|
88
|
+
const app = state.apps.find((a) => a.appId === appId);
|
|
89
|
+
if (app === undefined) return undefined;
|
|
90
|
+
const wire = app.services[service];
|
|
91
|
+
if (wire === undefined) return undefined;
|
|
92
|
+
const ports = new Set<number>();
|
|
93
|
+
for (const published of wire.hostPorts ?? []) ports.add(published.containerPort);
|
|
94
|
+
const routed = state.ingress.routedApps.find((a) => a.appId === appId);
|
|
95
|
+
for (const entry of routed?.services ?? []) {
|
|
96
|
+
if (entry.serviceId === service) ports.add(entry.router.containerPort);
|
|
97
|
+
}
|
|
98
|
+
return ports.size === 0 ? undefined : ports;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The Home → worker `uiProxy` handler, parse and all. */
|
|
102
|
+
export function uiProxyHandler(declaredPorts: DeclaredPorts): RpcHandler {
|
|
103
|
+
return async (req): Promise<RpcResponse> => {
|
|
104
|
+
let raw: unknown;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(new TextDecoder().decode(await readAll(req.body)));
|
|
107
|
+
} catch {
|
|
108
|
+
return jsonResponse(400, uiErrorBody("INTERNAL", "uiProxy: body is not JSON"));
|
|
109
|
+
}
|
|
110
|
+
const parsed = methods.uiProxy.params.safeParse(raw);
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
const issue = parsed.error.issues[0];
|
|
113
|
+
return jsonResponse(
|
|
114
|
+
400,
|
|
115
|
+
uiErrorBody("INTERNAL", `uiProxy: ${issue === undefined ? "invalid" : issue.message}`),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const res = await handleUiProxy(parsed.data, declaredPorts);
|
|
119
|
+
return {
|
|
120
|
+
status: res.status,
|
|
121
|
+
headers: res.headers,
|
|
122
|
+
body: bytesBody(new TextEncoder().encode(res.body)),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface ContainerEndpoint {
|
|
128
|
+
id: string;
|
|
129
|
+
ip: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The lookup either finds a dialable endpoint or explains why not, as a
|
|
133
|
+
// typed code. Listing with `all: true` and reading `State` is what
|
|
134
|
+
// separates the transient boot window (`CONTAINER_STARTING`, safe to
|
|
135
|
+
// retry) from a real failure (`CONTAINER_CRASHED`, surface it).
|
|
136
|
+
type ContainerLookup = { endpoint: ContainerEndpoint } | { code: UiErrorCode; message: string };
|
|
137
|
+
|
|
138
|
+
async function resolveContainerEndpoint(req: ProxyRequest): Promise<ContainerLookup> {
|
|
139
|
+
const filters = {
|
|
140
|
+
label: [`${LABELS.APP}=${req.appId}`, `${LABELS.SERVICE}=${req.service}`],
|
|
141
|
+
};
|
|
142
|
+
const summaries = await ode.listContainers({ all: true, filters });
|
|
143
|
+
if (summaries.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
code: "NO_CONTAINER",
|
|
146
|
+
message: `no container matches ${req.appId}/${req.service}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Multiple containers can match (replicas) — pick the first.
|
|
150
|
+
const summary = summaries[0]!;
|
|
151
|
+
const id = summary.Id;
|
|
152
|
+
if (!id) {
|
|
153
|
+
return {
|
|
154
|
+
code: "INTERNAL",
|
|
155
|
+
message: `container for ${req.appId}/${req.service} has no id`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// A container that exists but isn't running is either on its way up or
|
|
159
|
+
// down for good — `created`/`restarting` are the boot window, anything
|
|
160
|
+
// else is a real failure.
|
|
161
|
+
if (summary.State !== "running") {
|
|
162
|
+
const starting = summary.State === "created" || summary.State === "restarting";
|
|
163
|
+
return {
|
|
164
|
+
code: starting ? "CONTAINER_STARTING" : "CONTAINER_CRASHED",
|
|
165
|
+
message: `container for ${req.appId}/${req.service} is ${summary.State ?? "unknown"}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const detail = await ode.inspectContainer(id);
|
|
169
|
+
const networks = (detail.NetworkSettings?.Networks ?? {}) as Record<
|
|
170
|
+
string,
|
|
171
|
+
{ IPAddress?: string | null }
|
|
172
|
+
>;
|
|
173
|
+
// Prefer the per-app network so we don't accidentally route via a
|
|
174
|
+
// shared network that might be missing the container as a member.
|
|
175
|
+
const expectedNet = `realm_${req.appId}`;
|
|
176
|
+
const preferred = networks[expectedNet];
|
|
177
|
+
if (preferred?.IPAddress) return { endpoint: { id, ip: preferred.IPAddress } };
|
|
178
|
+
for (const endpoint of Object.values(networks)) {
|
|
179
|
+
if (endpoint?.IPAddress) return { endpoint: { id, ip: endpoint.IPAddress } };
|
|
180
|
+
}
|
|
181
|
+
// Running but no IP yet — still the boot window (network attach lags
|
|
182
|
+
// container start by a beat).
|
|
183
|
+
return {
|
|
184
|
+
code: "CONTAINER_STARTING",
|
|
185
|
+
message: `container for ${req.appId}/${req.service} has no network address yet`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function handleUiProxy(
|
|
190
|
+
parsed: ProxyRequest,
|
|
191
|
+
declaredPorts: DeclaredPorts,
|
|
192
|
+
): Promise<UiProxyResponse> {
|
|
193
|
+
const allowed = declaredPorts(parsed.appId, parsed.service);
|
|
194
|
+
if (allowed !== undefined && !allowed.has(parsed.port)) {
|
|
195
|
+
// Before the container lookup on purpose: the refusal must not depend on
|
|
196
|
+
// anything the container says about itself.
|
|
197
|
+
return coded(
|
|
198
|
+
400,
|
|
199
|
+
"INTERNAL",
|
|
200
|
+
`port ${parsed.port} is not declared by ${parsed.appId}/${parsed.service}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const lookup = await resolveContainerEndpoint(parsed);
|
|
205
|
+
if (!("endpoint" in lookup)) return coded(502, lookup.code, lookup.message);
|
|
206
|
+
const endpoint = lookup.endpoint;
|
|
207
|
+
|
|
208
|
+
// `path` may already carry a literal query string, so the stitch fn's
|
|
209
|
+
// `query` is merged onto it via URL rather than appended.
|
|
210
|
+
const target = new URL(`http://${endpoint.ip}:${parsed.port}${parsed.path}`);
|
|
211
|
+
if (parsed.query) {
|
|
212
|
+
for (const [k, v] of Object.entries(parsed.query)) target.searchParams.set(k, String(v));
|
|
213
|
+
}
|
|
214
|
+
const headers: Record<string, string> = parsed.headers ? { ...parsed.headers } : {};
|
|
215
|
+
const sendBody = parsed.body !== undefined && parsed.method !== "GET";
|
|
216
|
+
if (sendBody) headers["content-type"] = "application/json";
|
|
217
|
+
const init: RequestInit & { client: Deno.HttpClient } = {
|
|
218
|
+
method: parsed.method,
|
|
219
|
+
headers,
|
|
220
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
221
|
+
client: getProxyHttpClient(),
|
|
222
|
+
};
|
|
223
|
+
if (sendBody) init.body = JSON.stringify(parsed.body);
|
|
224
|
+
|
|
225
|
+
let upstream: Response;
|
|
226
|
+
try {
|
|
227
|
+
upstream = await fetch(target, init);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
const err = e as Error;
|
|
230
|
+
const timedOut = err.name === "TimeoutError";
|
|
231
|
+
return coded(
|
|
232
|
+
502,
|
|
233
|
+
timedOut ? "UPSTREAM_TIMEOUT" : "UPSTREAM_ERROR",
|
|
234
|
+
timedOut
|
|
235
|
+
? `upstream timed out after ${UPSTREAM_TIMEOUT_MS}ms`
|
|
236
|
+
: `upstream fetch failed: ${describeFetchError(err)}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Pass through JSON; bail out of non-JSON responses with a wrapped
|
|
241
|
+
// error so the iframe surface gets a structured failure.
|
|
242
|
+
const contentType = upstream.headers.get("content-type") ?? "";
|
|
243
|
+
let text: string;
|
|
244
|
+
try {
|
|
245
|
+
text = await readBoundedText(upstream, MAX_LOADER_RESPONSE_BYTES);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
const message =
|
|
248
|
+
e instanceof RangeError ? e.message : `upstream read failed: ${describeFetchError(e)}`;
|
|
249
|
+
return coded(502, "UPSTREAM_ERROR", message);
|
|
250
|
+
}
|
|
251
|
+
// An empty body is a success shape, not a contract break: `204 No Content`
|
|
252
|
+
// carries no content-type, so the JSON guard below would report a working
|
|
253
|
+
// PUT/DELETE as a 502. Normalize to `{}` and keep the upstream status —
|
|
254
|
+
// Home's action route still fails the call on a non-2xx.
|
|
255
|
+
if (text.trim() === "") return json(upstream.status, "{}");
|
|
256
|
+
if (!contentType.includes("application/json")) {
|
|
257
|
+
return coded(
|
|
258
|
+
502,
|
|
259
|
+
"UPSTREAM_ERROR",
|
|
260
|
+
`upstream returned non-JSON (${upstream.status}): ${text.slice(0, 256)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
// Relay the JSON bytes verbatim: a malformed body that still claims JSON
|
|
264
|
+
// passes through so the iframe's own parse surfaces the upstream error.
|
|
265
|
+
return json(upstream.status, text);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function json(status: number, body: string): UiProxyResponse {
|
|
269
|
+
return { status, headers: { "content-type": "application/json" }, body };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function coded(status: number, code: UiErrorCode, message: string): UiProxyResponse {
|
|
273
|
+
return json(status, JSON.stringify(uiErrorBody(code, message)));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function readBoundedText(res: Response, maxBytes: number): Promise<string> {
|
|
277
|
+
// Trust Content-Length when the server sends one — saves us streaming
|
|
278
|
+
// a multi-GB body just to throw it away.
|
|
279
|
+
const contentLength = res.headers.get("content-length");
|
|
280
|
+
if (contentLength !== null) {
|
|
281
|
+
const declared = Number(contentLength);
|
|
282
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
283
|
+
throw new RangeError(`upstream body exceeds ${maxBytes} bytes (declared ${declared})`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const reader = res.body?.getReader();
|
|
287
|
+
if (!reader) return "";
|
|
288
|
+
const chunks: Uint8Array[] = [];
|
|
289
|
+
let received = 0;
|
|
290
|
+
while (true) {
|
|
291
|
+
const { done, value } = await reader.read();
|
|
292
|
+
if (done) break;
|
|
293
|
+
received += value.byteLength;
|
|
294
|
+
if (received > maxBytes) {
|
|
295
|
+
await reader.cancel();
|
|
296
|
+
throw new RangeError(`upstream body exceeds ${maxBytes} bytes`);
|
|
297
|
+
}
|
|
298
|
+
chunks.push(value);
|
|
299
|
+
}
|
|
300
|
+
const merged = new Uint8Array(received);
|
|
301
|
+
let offset = 0;
|
|
302
|
+
for (const chunk of chunks) {
|
|
303
|
+
merged.set(chunk, offset);
|
|
304
|
+
offset += chunk.byteLength;
|
|
305
|
+
}
|
|
306
|
+
return new TextDecoder().decode(merged);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function bytesBody(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
310
|
+
return new ReadableStream<Uint8Array>(
|
|
311
|
+
{
|
|
312
|
+
pull: (controller) => {
|
|
313
|
+
if (bytes.length > 0) controller.enqueue(bytes);
|
|
314
|
+
controller.close();
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{ highWaterMark: 0 },
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function readAll(body: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
322
|
+
const chunks: Uint8Array[] = [];
|
|
323
|
+
let size = 0;
|
|
324
|
+
for await (const chunk of body) {
|
|
325
|
+
chunks.push(chunk);
|
|
326
|
+
size += chunk.length;
|
|
327
|
+
}
|
|
328
|
+
const out = new Uint8Array(size);
|
|
329
|
+
let offset = 0;
|
|
330
|
+
for (const chunk of chunks) {
|
|
331
|
+
out.set(chunk, offset);
|
|
332
|
+
offset += chunk.length;
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|