@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,792 @@
|
|
|
1
|
+
// host.* permission factories + per-kind Home-side mechanics.
|
|
2
|
+
//
|
|
3
|
+
// Each factory is built via `hostable(...)` (the local binding of
|
|
4
|
+
// `makeHostable(basePermission)`), which calls basePermission AND tags
|
|
5
|
+
// the kind's hooks (iterateUses / toValue / grantSchema) so the host
|
|
6
|
+
// registry derived in grants.ts can dispatch through them. The hooks
|
|
7
|
+
// are colocated with the factory: adding a host kind is one declaration.
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import type { Network } from "../identifiers.ts";
|
|
11
|
+
import { basePermission } from "../factory.ts";
|
|
12
|
+
import { makeHostable } from "../host-registry.ts";
|
|
13
|
+
import { RealmCompileError, SiloRequiresError } from "../errors.ts";
|
|
14
|
+
import type { ResolveArgs } from "../resolve-context.ts";
|
|
15
|
+
import { REALM_LOGS_MOUNT } from "../worker-protocol/logs-socket-path.ts";
|
|
16
|
+
import { REALM_STATS_MOUNT } from "../worker-protocol/stats-socket-path.ts";
|
|
17
|
+
import { SPAWN_PLACEHOLDER, spawnMounts, type SpawnValue } from "../worker-protocol/spawn-paths.ts";
|
|
18
|
+
import { MANAGEMENT_NET, siloNetwork } from "../worker-protocol/networks.ts";
|
|
19
|
+
import { speculativeSilo } from "../speculative-silo.ts";
|
|
20
|
+
|
|
21
|
+
const hostable = makeHostable(basePermission);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolved shape of a `host.management()` grant. The network is fixed
|
|
25
|
+
* (the SDK-side constant); the rest this kind's `resolve` hook reads off the
|
|
26
|
+
* eval context — `home_state` identity plus the running worker. Empty strings
|
|
27
|
+
* stand in for "unconfigured" so authors can guard with
|
|
28
|
+
* `if (config.management.baseDomain)`.
|
|
29
|
+
*/
|
|
30
|
+
export interface ManagementValue {
|
|
31
|
+
readonly network: Network;
|
|
32
|
+
readonly baseDomain: string;
|
|
33
|
+
readonly homeSlug: string;
|
|
34
|
+
readonly workerId: string;
|
|
35
|
+
// acme-dns credentials Home provisions from Realm DNS, or null when no
|
|
36
|
+
// DNS provider is configured (fresh install / BYO-domain offline mode).
|
|
37
|
+
// Injected at eval time, never stored in the app config.
|
|
38
|
+
readonly acmeDns: {
|
|
39
|
+
readonly serverUrl: string;
|
|
40
|
+
readonly username: string;
|
|
41
|
+
readonly apiKey: string;
|
|
42
|
+
// ACME directory URL. Empty = the proxy's default (Let's Encrypt prod).
|
|
43
|
+
// Set to a staging endpoint, or to a test CA (Pebble) in the e2e.
|
|
44
|
+
readonly ca: string;
|
|
45
|
+
// PEM root to trust for the ACME endpoint, for a non-publicly-trusted
|
|
46
|
+
// test CA (Pebble). Empty for real LE / staging (publicly trusted API).
|
|
47
|
+
readonly caRootPem: string;
|
|
48
|
+
} | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MANAGEMENT_PLACEHOLDER: ManagementValue = {
|
|
52
|
+
network: MANAGEMENT_NET,
|
|
53
|
+
baseDomain: "",
|
|
54
|
+
homeSlug: "",
|
|
55
|
+
workerId: "",
|
|
56
|
+
acmeDns: null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Roots an operator can never point a Silo's volume under. An operator-set
|
|
61
|
+
* path lands verbatim in a bind-mount source on the worker, so dangerous
|
|
62
|
+
* roots are veto'd at write time - a Silo backed by `/etc` or
|
|
63
|
+
* `/var/run/docker.sock` never reaches the DB. Lives here (not in
|
|
64
|
+
* security.ts) to avoid the cycle schema/host.ts <-> security.ts <->
|
|
65
|
+
* grants.ts; security.ts re-exports for Home UI consumers.
|
|
66
|
+
*/
|
|
67
|
+
export const HOST_DIRECTORY_BLOCKLIST = [
|
|
68
|
+
"/",
|
|
69
|
+
"/proc",
|
|
70
|
+
"/sys",
|
|
71
|
+
"/dev",
|
|
72
|
+
"/run",
|
|
73
|
+
"/var/run",
|
|
74
|
+
"/var/lib/docker",
|
|
75
|
+
"/etc",
|
|
76
|
+
"/root",
|
|
77
|
+
"/home",
|
|
78
|
+
"/opt/realm",
|
|
79
|
+
"/boot",
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The one spelling of a host-absolute path, or `null` when the string can't be
|
|
84
|
+
* one: `//mnt///media/` and `/mnt/./media` both collapse to `/mnt/media`, and
|
|
85
|
+
* anything relative or carrying a `..` segment is refused outright rather than
|
|
86
|
+
* resolved (a path that climbs is a path whose target depends on where it was
|
|
87
|
+
* written, which is not something an admin-set string gets to do).
|
|
88
|
+
*
|
|
89
|
+
* Returning the canonical string is the point: two paths that name the same
|
|
90
|
+
* directory have to compare equal before anything can be stored or checked for
|
|
91
|
+
* overlap against what's already stored.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeHostPath(raw: string): string | null {
|
|
94
|
+
if (!raw.startsWith("/")) return null;
|
|
95
|
+
const segments: string[] = [];
|
|
96
|
+
for (const segment of raw.split("/")) {
|
|
97
|
+
if (segment === "" || segment === ".") continue;
|
|
98
|
+
if (segment === "..") return null;
|
|
99
|
+
segments.push(segment);
|
|
100
|
+
}
|
|
101
|
+
return `/${segments.join("/")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether two normalized paths name the same tree - equal, or either inside the
|
|
106
|
+
* other. Compared on separator boundaries, so `/mnt/nas/media` and
|
|
107
|
+
* `/mnt/nas/media-old` are unrelated rather than nested.
|
|
108
|
+
*
|
|
109
|
+
* Both arguments must already be through `normalizeHostPath`; a trailing slash
|
|
110
|
+
* or a doubled separator would read as a different tree.
|
|
111
|
+
*/
|
|
112
|
+
export function pathsOverlap(a: string, b: string): boolean {
|
|
113
|
+
if (a === b) return true;
|
|
114
|
+
const contains = (parent: string, child: string) =>
|
|
115
|
+
child.startsWith(parent === "/" ? "/" : `${parent}/`);
|
|
116
|
+
return contains(a, b) || contains(b, a);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isValidHostDirectoryPath(raw: unknown): raw is string {
|
|
120
|
+
if (typeof raw !== "string") return false;
|
|
121
|
+
const normalized = normalizeHostPath(raw);
|
|
122
|
+
if (normalized === null) return false;
|
|
123
|
+
for (const blocked of HOST_DIRECTORY_BLOCKLIST) {
|
|
124
|
+
if (normalized === blocked) return false;
|
|
125
|
+
// Not `pathsOverlap`: containment here is one-directional. Everything is
|
|
126
|
+
// under `/`, so the root entry blocks itself and nothing else.
|
|
127
|
+
if (blocked === "/") continue;
|
|
128
|
+
if (normalized.startsWith(blocked + "/")) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pass through the host's GPU to the container.
|
|
135
|
+
*
|
|
136
|
+
* Gives the container access to the graphics card via Docker device
|
|
137
|
+
* requests. Used for hardware-accelerated workloads like video transcoding.
|
|
138
|
+
*
|
|
139
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/gpu/
|
|
140
|
+
*/
|
|
141
|
+
export const gpu = hostable(
|
|
142
|
+
"host.gpu",
|
|
143
|
+
z.literal(true).default(true),
|
|
144
|
+
{
|
|
145
|
+
iterateUses: (svc) => (svc.gpu === true ? [true] : []),
|
|
146
|
+
toValue: () => true as const,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "GPU passthrough",
|
|
150
|
+
phrase: "GPU passthrough (/dev/dri)",
|
|
151
|
+
helpText: "This app gets access to your graphics card.",
|
|
152
|
+
icon: "GraphicsCardIcon",
|
|
153
|
+
appKinds: "both",
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Publish a port on the host machine.
|
|
159
|
+
*
|
|
160
|
+
* The app declares the port it wants; the admin can approve it as-is or
|
|
161
|
+
* change the port number on the consent screen.
|
|
162
|
+
*
|
|
163
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/port/
|
|
164
|
+
*/
|
|
165
|
+
export const port = hostable(
|
|
166
|
+
"host.port",
|
|
167
|
+
z.number().int().min(1).max(65535),
|
|
168
|
+
{
|
|
169
|
+
iterateUses: (svc) => (svc.hostPorts ?? []).map((hp) => hp.hostPort),
|
|
170
|
+
toValue: (data: number) => ({ port: data }),
|
|
171
|
+
// Admin-grant input arrives as a raw string from the form (we don't
|
|
172
|
+
// coerce in the UI so an empty input stays empty). Coerce here, then
|
|
173
|
+
// re-validate the int range.
|
|
174
|
+
grantSchema: z.coerce.number().int().min(1).max(65535),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "Port",
|
|
178
|
+
phrase: "bind a host port",
|
|
179
|
+
helpText:
|
|
180
|
+
"This port will be reachable from the internet. You can change the port number before approving.",
|
|
181
|
+
icon: "PlugIcon",
|
|
182
|
+
appKinds: "both",
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Branded host-absolute path owned by a Silo. Mirrors `PeerHostPath` /
|
|
188
|
+
* `WorkerHostPath`: lets the Silo's operator-chosen directory flow into
|
|
189
|
+
* `volumes.hostPath` without widening the literal-path type to arbitrary
|
|
190
|
+
* `string`.
|
|
191
|
+
*/
|
|
192
|
+
export type SiloHostPath = string & { readonly __siloHostPath: unique symbol };
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Services a member requires from its Silo: service name -> the port it
|
|
196
|
+
* answers on. The richer form of the `network` affordance - `network: true`
|
|
197
|
+
* is membership alone, a require-map is membership plus resolution.
|
|
198
|
+
*/
|
|
199
|
+
export type SiloRequires = Readonly<Record<string, number>>;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* How a member uses the Silo's shared directory. Declared, never guessed:
|
|
203
|
+
* writability is the biggest thing a Silo hands out, so it's the app's stated
|
|
204
|
+
* intent and the operator's consent, not a property of the path string.
|
|
205
|
+
*/
|
|
206
|
+
export type SiloVolumeMode = "read" | "readwrite";
|
|
207
|
+
|
|
208
|
+
/** The three affordances a member can ask a Silo for. */
|
|
209
|
+
export interface SiloGrants {
|
|
210
|
+
readonly network?: true | SiloRequires;
|
|
211
|
+
readonly volume?: SiloVolumeMode;
|
|
212
|
+
readonly apiKey?: true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `SiloGrants` with at least one affordance set. Joining a Silo without
|
|
217
|
+
* asking it for anything means nothing, so `r.host.silo({})` is a compile
|
|
218
|
+
* error rather than a no-op grant the operator has to reason about.
|
|
219
|
+
*/
|
|
220
|
+
export type SiloGrantsInput = {
|
|
221
|
+
[K in keyof SiloGrants]-?: Required<Pick<SiloGrants, K>> & Partial<SiloGrants>;
|
|
222
|
+
}[keyof SiloGrants];
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The Silo's shared directory as a complete volume entry - it goes straight
|
|
226
|
+
* into `volumes` and is mounted verbatim, so every member sees the same bytes
|
|
227
|
+
* at the same `containerPath` and can exchange absolute paths at runtime.
|
|
228
|
+
*/
|
|
229
|
+
export interface SiloVolume {
|
|
230
|
+
readonly hostPath: SiloHostPath;
|
|
231
|
+
readonly containerPath: string;
|
|
232
|
+
/**
|
|
233
|
+
* The Silo's group. The worker adds `group_add: [gid]` to any service that
|
|
234
|
+
* mounts a gid-carrying volume, so filesystem membership lands exactly where
|
|
235
|
+
* the mount lands - no cooperation needed from the image.
|
|
236
|
+
*/
|
|
237
|
+
readonly gid: number;
|
|
238
|
+
/** `true` for `volume: "read"`, `false` for `volume: "readwrite"`. */
|
|
239
|
+
readonly readOnly: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* Always `false`: a member's mount is never how a Silo gets backed up.
|
|
242
|
+
* Whether the shared bytes are backed up is the operator's answer on the
|
|
243
|
+
* Silo record, and it reaches the backup app from there - so no member
|
|
244
|
+
* speaks for the commons, and two members mounting the same tree can't
|
|
245
|
+
* enroll it twice. Stamped rather than left absent so a volume with no
|
|
246
|
+
* declared strategy stays a signal about volumes an author actually wrote.
|
|
247
|
+
*/
|
|
248
|
+
readonly backup: false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type ServicesFrom<N extends SiloRequires> = {
|
|
252
|
+
readonly [K in keyof N]: { readonly host: string; readonly port: number };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What `config.silo` resolves to, narrowed to the affordances that were
|
|
257
|
+
* actually asked for - reading `config.silo.apiKey` without declaring
|
|
258
|
+
* `apiKey: true` is a compile error, not an undefined at runtime.
|
|
259
|
+
*
|
|
260
|
+
* `services` only appears for a require-map, and contains exactly the names
|
|
261
|
+
* that were required: it answers "where is the thing I said I need", not
|
|
262
|
+
* "who else is in this Silo", so there is no roster to iterate and no
|
|
263
|
+
* possibly-undefined lookup to guard.
|
|
264
|
+
*/
|
|
265
|
+
export type SiloValue<G extends SiloGrants> = (G extends { network: infer N }
|
|
266
|
+
? N extends SiloRequires
|
|
267
|
+
? { readonly network: Network; readonly services: ServicesFrom<N> }
|
|
268
|
+
: { readonly network: Network }
|
|
269
|
+
: unknown) &
|
|
270
|
+
(G extends { volume: SiloVolumeMode } ? { readonly volume: SiloVolume } : unknown) &
|
|
271
|
+
(G extends { apiKey: true } ? { readonly apiKey: string } : unknown);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The widest resolved shape - every affordance optional. This is what the
|
|
275
|
+
* runtime deals in (`resolveSilo` builds it per-declaration from the Silo
|
|
276
|
+
* record on the eval context);
|
|
277
|
+
* the per-declaration narrowing to `SiloValue<G>` is type-level only, applied
|
|
278
|
+
* by `ValueOf` in grants.ts off the author's declared flags.
|
|
279
|
+
*/
|
|
280
|
+
export type SiloResolved = {
|
|
281
|
+
readonly network?: Network;
|
|
282
|
+
readonly services?: Readonly<Record<string, { readonly host: string; readonly port: number }>>;
|
|
283
|
+
readonly volume?: SiloVolume;
|
|
284
|
+
readonly apiKey?: string;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* One require-map entry the Silo can't satisfy: the name isn't published at
|
|
289
|
+
* all (`offeredPort` absent) or is published on a different port.
|
|
290
|
+
*/
|
|
291
|
+
export interface SiloRequireUnmet {
|
|
292
|
+
readonly name: string;
|
|
293
|
+
readonly port: number;
|
|
294
|
+
readonly offeredPort?: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The guided message for a require-map the Silo doesn't meet. Shared between
|
|
299
|
+
* Home eval and the SDK test harness so both surfaces emit byte-identical
|
|
300
|
+
* text (the harness asserts against it).
|
|
301
|
+
*/
|
|
302
|
+
export function siloRequiresUnmetMessage(
|
|
303
|
+
siloName: string,
|
|
304
|
+
unmet: readonly SiloRequireUnmet[],
|
|
305
|
+
): string {
|
|
306
|
+
const parts = unmet.map((u) =>
|
|
307
|
+
u.offeredPort === undefined
|
|
308
|
+
? `doesn't offer "${u.name}" on port ${u.port}`
|
|
309
|
+
: `offers "${u.name}" on ${u.offeredPort}, not ${u.port}`,
|
|
310
|
+
);
|
|
311
|
+
return `Silo "${siloName}" ${parts.join("; ")}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Type-level only, so `ValueOf` can read the shape off `toValue` (mirrors
|
|
315
|
+
// `host.management` / `home.apiAccess`). Every real value comes from
|
|
316
|
+
// `resolveSilo`, built from the Silo record on the eval context.
|
|
317
|
+
const SILO_PLACEHOLDER: SiloResolved = {};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve a `host.silo` declaration against the Silo the operator bound the
|
|
321
|
+
* app to.
|
|
322
|
+
*
|
|
323
|
+
* Two sides meet here: `declared` is the author's asked-for affordances,
|
|
324
|
+
* `grant` is the operator's chosen Silo id. Neither alone is enough - the
|
|
325
|
+
* author says what they want, the operator says from where.
|
|
326
|
+
*
|
|
327
|
+
* Throws rather than resolving a partial value: an app that can't get what it
|
|
328
|
+
* asked for must drop with a readable message, not push an `undefined` into a
|
|
329
|
+
* mount and fail somewhere further downstream.
|
|
330
|
+
*
|
|
331
|
+
* The throws are `validation` issues, not `eval` ones, because every failure
|
|
332
|
+
* here is the operator's to fix (wrong Silo, wrong worker, a require the Silo
|
|
333
|
+
* doesn't meet) and every message says only what they already know - their own
|
|
334
|
+
* permission path, their own Silo id, their own worker. `eval` issues get
|
|
335
|
+
* redacted to a generic string at the HTTP boundary precisely because they can
|
|
336
|
+
* carry internals; these carry none, so they reach the operator intact.
|
|
337
|
+
*/
|
|
338
|
+
function resolveSilo({ grant, declared, path, ctx }: ResolveArgs<unknown>): SiloResolved {
|
|
339
|
+
// Annotated rather than inferred: TypeScript only narrows past a
|
|
340
|
+
// `never`-returning call when the binding carries an explicit type.
|
|
341
|
+
const fail: (message: string) => never = (message) => {
|
|
342
|
+
throw new RealmCompileError({ kind: "validation", path: path.split("."), message });
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (typeof grant !== "string") {
|
|
346
|
+
// A speculative eval has no operator behind it, so "you didn't pick a
|
|
347
|
+
// Silo" answers a question nobody has been asked yet - and refusing there
|
|
348
|
+
// drops the app out of its own install preview.
|
|
349
|
+
//
|
|
350
|
+
// Both grant-writing routes run `sanitizeGrants`, whose `grantSchema` for
|
|
351
|
+
// this kind is a non-empty string, so a Silo grant with no id is a 400 long
|
|
352
|
+
// before it reaches here; the `fail` below is defence in depth.
|
|
353
|
+
if (ctx.speculative) return speculativeSilo(declared as SiloGrants);
|
|
354
|
+
fail("no Silo was picked for this permission");
|
|
355
|
+
}
|
|
356
|
+
const silo = ctx.silos.find((s) => s.id === grant);
|
|
357
|
+
if (!silo) fail(`Silo "${grant}" no longer exists`);
|
|
358
|
+
if (ctx.worker.internalId !== undefined && silo.workerId !== ctx.worker.internalId) {
|
|
359
|
+
// A Silo's bytes are a real directory on one box, so a member has to run
|
|
360
|
+
// on that box. Reachable by deploying a silo app to every worker.
|
|
361
|
+
fail(`Silo "${silo.name}" is on worker ${silo.workerSlug}, so this app must run there too`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const asked = declared as SiloGrants;
|
|
365
|
+
const resolved: SiloResolved = {};
|
|
366
|
+
if (asked.network !== undefined) {
|
|
367
|
+
(resolved as { network: SiloResolved["network"] }).network = siloNetwork(silo.id);
|
|
368
|
+
if (asked.network !== true) {
|
|
369
|
+
// A require-map gates on the merged directory of the Silo's other
|
|
370
|
+
// members: every required name must be published on exactly the
|
|
371
|
+
// required port. Thrown as its own subclass so the pass structure can
|
|
372
|
+
// retry the drop when the directory later grows.
|
|
373
|
+
const offered = ctx.siloDirectory(silo.id);
|
|
374
|
+
const unmet: SiloRequireUnmet[] = [];
|
|
375
|
+
for (const [name, port] of Object.entries(asked.network)) {
|
|
376
|
+
const entry = offered.get(name);
|
|
377
|
+
if (entry === undefined) unmet.push({ name, port });
|
|
378
|
+
else if (entry.port !== port) unmet.push({ name, port, offeredPort: entry.port });
|
|
379
|
+
}
|
|
380
|
+
if (unmet.length > 0) {
|
|
381
|
+
throw new SiloRequiresError({
|
|
382
|
+
kind: "validation",
|
|
383
|
+
path: path.split("."),
|
|
384
|
+
message: siloRequiresUnmetMessage(silo.name, unmet),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const services: Record<string, { host: string; port: number }> = {};
|
|
388
|
+
for (const [name, port] of Object.entries(asked.network)) {
|
|
389
|
+
// The published name IS the host: the worker sets a matching
|
|
390
|
+
// network alias on the silo bridge at connect time.
|
|
391
|
+
services[name] = { host: name, port };
|
|
392
|
+
}
|
|
393
|
+
(resolved as { services: SiloResolved["services"] }).services = services;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (asked.volume !== undefined) {
|
|
397
|
+
(resolved as { volume: SiloResolved["volume"] }).volume = {
|
|
398
|
+
hostPath: silo.hostPath as SiloHostPath,
|
|
399
|
+
containerPath: silo.containerPath,
|
|
400
|
+
gid: silo.gid,
|
|
401
|
+
// The author declared the mode and the operator approved it; a member
|
|
402
|
+
// that only asked to read gets a mount it cannot write through.
|
|
403
|
+
readOnly: asked.volume === "read",
|
|
404
|
+
backup: false,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (asked.apiKey !== undefined) {
|
|
408
|
+
// Derived for every Silo up front, so present whenever the exists-check
|
|
409
|
+
// above passed.
|
|
410
|
+
(resolved as { apiKey: SiloResolved["apiKey"] }).apiKey = ctx.siloApiKeys.get(silo.id)!;
|
|
411
|
+
}
|
|
412
|
+
return resolved;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const siloRequiresSchema = z.record(z.string(), z.number().int().min(1).max(65535));
|
|
416
|
+
|
|
417
|
+
const siloGrantsSchema = z
|
|
418
|
+
.object({
|
|
419
|
+
network: z.union([z.literal(true), siloRequiresSchema]).optional(),
|
|
420
|
+
volume: z.enum(["read", "readwrite"]).optional(),
|
|
421
|
+
apiKey: z.literal(true).optional(),
|
|
422
|
+
})
|
|
423
|
+
.refine((g) => g.network !== undefined || g.volume !== undefined || g.apiKey !== undefined, {
|
|
424
|
+
message: "silo() needs at least one of network, volume, apiKey",
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const siloFactory = hostable(
|
|
428
|
+
"host.silo",
|
|
429
|
+
siloGrantsSchema,
|
|
430
|
+
{
|
|
431
|
+
// The elevated use is the communal volume: a mount carrying a gid reads
|
|
432
|
+
// (or writes) another trust domain's bytes and joins its group. All three
|
|
433
|
+
// of hostPath, gid and writability have to match a Silo the operator bound
|
|
434
|
+
// this app to - a hand-written gid, a Silo path without the join, or a
|
|
435
|
+
// writable mount from an app that only asked to read, all fail the gate.
|
|
436
|
+
// Network membership and the apiKey have no per-service use; they fall out
|
|
437
|
+
// of the grant being in place.
|
|
438
|
+
iterateUses: function* (svc) {
|
|
439
|
+
for (const v of svc.volumes ?? []) {
|
|
440
|
+
if (v.gid !== undefined) {
|
|
441
|
+
yield { hostPath: v.hostPath, gid: v.gid, readOnly: v.readOnly === true };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
// Type-level only. The grant carries a Silo id, which means nothing
|
|
446
|
+
// without the Silo record, so every real value comes from `resolve`
|
|
447
|
+
// below; this exists so `ValueOf` can read the resolved shape.
|
|
448
|
+
toValue: (): SiloResolved => SILO_PLACEHOLDER,
|
|
449
|
+
resolve: resolveSilo,
|
|
450
|
+
// The author declares which affordances they want; the grant carries
|
|
451
|
+
// which Silo the operator bound (its id).
|
|
452
|
+
grantSchema: z.string().min(1),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: "Silo",
|
|
456
|
+
phrase: "join a Silo",
|
|
457
|
+
helpText:
|
|
458
|
+
"This app joins a Silo you pick - an operator-owned network, shared directory, and shared credential. Everything in a Silo can see everything else in it, so only put apps together that you mean to share.",
|
|
459
|
+
icon: "StackIcon",
|
|
460
|
+
// Single-only: a Silo is a directory on one box, so the grant means
|
|
461
|
+
// nothing spread across a fleet.
|
|
462
|
+
appKinds: "single",
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* A `silo()` record with the author's declared flags preserved on `data`.
|
|
468
|
+
*
|
|
469
|
+
* Every other permission's `data` is `z.output<schema>` - one fixed type per
|
|
470
|
+
* kind, which is all `FactoryFor` can express. Silo is the one kind whose
|
|
471
|
+
* resolved value depends on what was asked for, so the factory is re-typed
|
|
472
|
+
* generically and the chain methods are re-declared here. They have to be
|
|
473
|
+
* re-declared rather than inherited: `Omit` instantiates polymorphic `this`
|
|
474
|
+
* against the *source* type, so an inherited `.reason()` would silently hand
|
|
475
|
+
* back the wide record and undo the narrowing. Inside an interface, `this`
|
|
476
|
+
* survives.
|
|
477
|
+
*/
|
|
478
|
+
interface SiloRecord<G extends SiloGrantsInput> extends Omit<
|
|
479
|
+
ReturnType<typeof siloFactory>,
|
|
480
|
+
"data" | "reason" | "describe" | "optional"
|
|
481
|
+
> {
|
|
482
|
+
readonly data: G;
|
|
483
|
+
reason(text: string): this & { readonly meta: { readonly reason: string } };
|
|
484
|
+
describe(text: string): this;
|
|
485
|
+
optional(): this & { readonly meta: { readonly isOptional: true } };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Join an operator-owned Silo: a shared network, a shared directory, and a
|
|
490
|
+
* shared credential that outlive every app in them.
|
|
491
|
+
*
|
|
492
|
+
* The operator creates the Silo and points apps at it, so a set of apps
|
|
493
|
+
* installed separately can share files and talk to each other without any of
|
|
494
|
+
* them owning the anchor. Ask for the affordances you need and nothing else -
|
|
495
|
+
* `config.silo` is typed to exactly what you declared.
|
|
496
|
+
*
|
|
497
|
+
* ```ts
|
|
498
|
+
* silo: r.host
|
|
499
|
+
* .silo({ network: { sabnzbd: 8080 }, volume: "readwrite" })
|
|
500
|
+
* .reason("Import finished downloads into your library"),
|
|
501
|
+
* ```
|
|
502
|
+
*
|
|
503
|
+
* A require-map (rather than `network: true`) both gates the install - the
|
|
504
|
+
* operator can't pick a Silo that doesn't offer those services - and resolves
|
|
505
|
+
* those names into `config.silo.services`.
|
|
506
|
+
*
|
|
507
|
+
* `volume` says what you do with the shared bytes: `"read"` mounts them
|
|
508
|
+
* read-only, `"readwrite"` lets you write. Ask for `"read"` when that's all
|
|
509
|
+
* you need - it's the difference the operator sees on the consent screen.
|
|
510
|
+
*
|
|
511
|
+
* For storage the app keeps to itself, don't reach for this: a relative
|
|
512
|
+
* `volumes` path persists with no permission and no operator decision.
|
|
513
|
+
*
|
|
514
|
+
* Only a single app (`createApp`) may declare this. A Silo is a directory on
|
|
515
|
+
* one box, so a member runs on that box - "every compatible worker" is the
|
|
516
|
+
* opposite claim.
|
|
517
|
+
*
|
|
518
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/silo/
|
|
519
|
+
*/
|
|
520
|
+
export const silo = siloFactory as unknown as Omit<typeof siloFactory, never> &
|
|
521
|
+
(<G extends SiloGrantsInput>(grants: G) => SiloRecord<G>);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Join the Home's management bridge network and read Home-side identity.
|
|
525
|
+
*
|
|
526
|
+
* Bundles the bridge network with the dashboard URL pieces every system-app
|
|
527
|
+
* proxy or sidecar tends to want at once - `network` (the management bridge),
|
|
528
|
+
* `baseDomain` and `homeSlug` (owned by Home's `home_state`, set by the
|
|
529
|
+
* installer or operator), and `workerId` (the running worker's short slug).
|
|
530
|
+
*
|
|
531
|
+
* The `network` half is the SDK-known constant; the rest come off the eval
|
|
532
|
+
* context. Empty strings stand in for "not yet configured" - guard with
|
|
533
|
+
* `if (config.management.baseDomain && config.management.homeSlug)` before
|
|
534
|
+
* deriving URLs.
|
|
535
|
+
*
|
|
536
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/management/
|
|
537
|
+
*/
|
|
538
|
+
export const management = hostable(
|
|
539
|
+
"host.management",
|
|
540
|
+
z.literal(true).default(true),
|
|
541
|
+
{
|
|
542
|
+
// The "use" of management is implicit — joining the network and
|
|
543
|
+
// reading the URL pieces falls out of the grant being in place;
|
|
544
|
+
// nothing in the resolved service spec distinguishes a user from a
|
|
545
|
+
// non-user.
|
|
546
|
+
iterateUses: () => [],
|
|
547
|
+
// Type-level only. The network is the SDK-side constant, but every other
|
|
548
|
+
// field is Home's, so the real value comes from `resolve`.
|
|
549
|
+
toValue: (): ManagementValue => MANAGEMENT_PLACEHOLDER,
|
|
550
|
+
resolve: ({ ctx }): ManagementValue => ({
|
|
551
|
+
network: MANAGEMENT_NET,
|
|
552
|
+
baseDomain: ctx.home.baseDomain,
|
|
553
|
+
homeSlug: ctx.home.homeSlug,
|
|
554
|
+
workerId: ctx.worker.id,
|
|
555
|
+
// acme-dns username is the domain slug = the first label of baseDomain.
|
|
556
|
+
acmeDns: ctx.home.acmeDns
|
|
557
|
+
? {
|
|
558
|
+
serverUrl: ctx.home.acmeDns.serverUrl,
|
|
559
|
+
username: ctx.home.baseDomain.split(".")[0] ?? "",
|
|
560
|
+
apiKey: ctx.home.acmeDns.apiKey,
|
|
561
|
+
ca: ctx.home.acmeDns.ca,
|
|
562
|
+
caRootPem: ctx.home.acmeDns.caRootPem,
|
|
563
|
+
}
|
|
564
|
+
: null,
|
|
565
|
+
}),
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "Management bridge + Home identity",
|
|
569
|
+
phrase: "join the management bridge network and read Home identity",
|
|
570
|
+
helpText:
|
|
571
|
+
"This grants access to the management bridge network and the Home's dashboard URL pieces.",
|
|
572
|
+
icon: "ShareNetworkIcon",
|
|
573
|
+
danger: true,
|
|
574
|
+
appKinds: "both",
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Install host-level iptables and ip6tables rules from the container.
|
|
580
|
+
*
|
|
581
|
+
* Unlocks the service-level `iptables: true` field. The renderer expands
|
|
582
|
+
* that into the primitives the firewall needs - the `NET_ADMIN` capability
|
|
583
|
+
* to mutate netfilter rules, plus host network mode so the rules apply to
|
|
584
|
+
* host traffic and Docker's `DOCKER-USER` chain.
|
|
585
|
+
*
|
|
586
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/iptables/
|
|
587
|
+
*/
|
|
588
|
+
export const iptables = hostable(
|
|
589
|
+
"host.iptables",
|
|
590
|
+
z.literal(true).default(true),
|
|
591
|
+
{
|
|
592
|
+
iterateUses: (svc) => (svc.iptables === true ? [true] : []),
|
|
593
|
+
toValue: () => true as const,
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
name: "Host iptables",
|
|
597
|
+
phrase: "install host iptables and ip6tables rules",
|
|
598
|
+
helpText:
|
|
599
|
+
"This app installs firewall rules on the host. Required for the firewall app to lock down which ports are reachable.",
|
|
600
|
+
icon: "ShieldIcon",
|
|
601
|
+
danger: true,
|
|
602
|
+
appKinds: "both",
|
|
603
|
+
},
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Allow outbound internet access from the container.
|
|
608
|
+
*
|
|
609
|
+
* Without this permission, app networks are created with Docker's
|
|
610
|
+
* `internal: true` flag - containers can talk to peers they have explicit
|
|
611
|
+
* permission for, but cannot reach the outside world.
|
|
612
|
+
*
|
|
613
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/egress/
|
|
614
|
+
*/
|
|
615
|
+
export const egress = hostable(
|
|
616
|
+
"host.egress",
|
|
617
|
+
z.literal(true).default(true),
|
|
618
|
+
{
|
|
619
|
+
iterateUses: () => [],
|
|
620
|
+
toValue: () => true as const,
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: "Internet access",
|
|
624
|
+
phrase: "outbound internet access",
|
|
625
|
+
helpText:
|
|
626
|
+
"Without this, the app can only communicate with other apps on your server. Granting this allows it to make outbound connections to the internet.",
|
|
627
|
+
icon: "GlobeIcon",
|
|
628
|
+
appKinds: "both",
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Receive a stream of every container's stdout and stderr from the worker
|
|
634
|
+
* via a unix socket on the host.
|
|
635
|
+
*
|
|
636
|
+
* Resolves to the mount for the worker's logs socket DIRECTORY — spread it
|
|
637
|
+
* into `volumes` (`volumes: [config.logs]`, or override `containerPath` to put
|
|
638
|
+
* it elsewhere). Inside the container, connect to the `logs.sock` inside the
|
|
639
|
+
* mounted dir with `connectToLogsSocket` / `streamLogs` from
|
|
640
|
+
* `@yourrealm/sdk/runtime/logs-client`. (The mount is the directory, not the
|
|
641
|
+
* socket file: a file bind-mount of a unix socket doesn't wire across
|
|
642
|
+
* containers on macOS Docker.)
|
|
643
|
+
*
|
|
644
|
+
* `readOnly: true` and not negotiable — the directory is shared by every app
|
|
645
|
+
* holding this grant, so a writable mount would let any of them unlink the
|
|
646
|
+
* socket and put their own listener in its place. Overriding it is a grant
|
|
647
|
+
* violation, not a preference.
|
|
648
|
+
*
|
|
649
|
+
* The grant exempts the resolved path from the absolute-mount gate, so a
|
|
650
|
+
* bare `host.logs()` is enough to wire the volume.
|
|
651
|
+
*
|
|
652
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/logs/
|
|
653
|
+
*/
|
|
654
|
+
/**
|
|
655
|
+
* Probe-the-UX permission. No worker advertises this kind, so an app
|
|
656
|
+
* that declares it can only succeed once a (hypothetical) worker that
|
|
657
|
+
* supports it enrolls. Useful for exercising the
|
|
658
|
+
* "no worker can satisfy this required permission" path in the
|
|
659
|
+
* install panel without a real driver behind it.
|
|
660
|
+
*/
|
|
661
|
+
export const rocketLaunch = hostable(
|
|
662
|
+
"host.rocketLaunch",
|
|
663
|
+
z.literal(true).default(true),
|
|
664
|
+
{
|
|
665
|
+
iterateUses: () => [],
|
|
666
|
+
toValue: () => true as const,
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "Rocket launch",
|
|
670
|
+
phrase: "launch a rocket",
|
|
671
|
+
helpText:
|
|
672
|
+
"Permission with no worker that can satisfy it. Used to exercise the unsupported-permission UX.",
|
|
673
|
+
icon: "RocketLaunchIcon",
|
|
674
|
+
appKinds: "both",
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
export const logs = hostable(
|
|
679
|
+
"host.logs",
|
|
680
|
+
z.literal(true).default(true),
|
|
681
|
+
{
|
|
682
|
+
// The "use" of host.logs is implicit — mounting the socket dir falls out
|
|
683
|
+
// of the grant being in place; the resolved hostPath is exempted from the
|
|
684
|
+
// absolute-mount check via `collectAllowed` in the Home eval graph. That
|
|
685
|
+
// exemption carries the read-only requirement with it, so an app that
|
|
686
|
+
// mounts the shared dir writable is rejected there.
|
|
687
|
+
iterateUses: () => [],
|
|
688
|
+
toValue: () => REALM_LOGS_MOUNT,
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: "Container logs",
|
|
692
|
+
phrase: "stream container logs from every app",
|
|
693
|
+
helpText:
|
|
694
|
+
"This app receives the stdout and stderr of every container running on this Worker. Useful for log forwarders and audit collectors.",
|
|
695
|
+
icon: "TerminalWindowIcon",
|
|
696
|
+
danger: true,
|
|
697
|
+
appKinds: "both",
|
|
698
|
+
},
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Ask the worker for a point-in-time sample of the host and every container
|
|
703
|
+
* running on it, over a unix socket.
|
|
704
|
+
*
|
|
705
|
+
* The read-only counterpart to `host.logs`: same broker shape, same
|
|
706
|
+
* all-or-nothing scope, but request/response instead of a stream. Resolves to
|
|
707
|
+
* the mount for the worker's stats socket DIRECTORY — spread it into `volumes`
|
|
708
|
+
* (`volumes: [config.stats]`, or override `containerPath`). Inside the
|
|
709
|
+
* container, connect to the `stats.sock` inside the mounted dir with
|
|
710
|
+
* `sampleStats` from `@yourrealm/sdk/runtime/stats-client`. (The mount is the
|
|
711
|
+
* directory, not the socket file: a file bind-mount of a unix socket doesn't
|
|
712
|
+
* wire across containers on macOS Docker.) `readOnly: true` is fixed, for the
|
|
713
|
+
* same shared-directory reason as `host.logs`.
|
|
714
|
+
*
|
|
715
|
+
* The grant exempts the resolved path from the absolute-mount gate, so a
|
|
716
|
+
* bare `host.stats()` is enough to wire the volume.
|
|
717
|
+
*
|
|
718
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/stats/
|
|
719
|
+
*/
|
|
720
|
+
export const stats = hostable(
|
|
721
|
+
"host.stats",
|
|
722
|
+
z.literal(true).default(true),
|
|
723
|
+
{
|
|
724
|
+
// The "use" of host.stats is implicit — mounting the socket dir falls out
|
|
725
|
+
// of the grant being in place; the resolved hostPath is exempted from the
|
|
726
|
+
// absolute-mount check via `collectAllowed` in the Home eval graph. That
|
|
727
|
+
// exemption carries the read-only requirement with it, so an app that
|
|
728
|
+
// mounts the shared dir writable is rejected there.
|
|
729
|
+
iterateUses: () => [],
|
|
730
|
+
toValue: () => REALM_STATS_MOUNT,
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "Container stats",
|
|
734
|
+
phrase: "read resource usage for every app's containers",
|
|
735
|
+
helpText:
|
|
736
|
+
"This app can sample the resource usage of every container running on this Worker, plus the host's own load, memory, and disks. Read-only: it can see your fleet, but can't touch it.",
|
|
737
|
+
icon: "PulseIcon",
|
|
738
|
+
danger: true,
|
|
739
|
+
appKinds: "both",
|
|
740
|
+
},
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Spawn disposable, fully-hardened containers on the worker - one box per
|
|
745
|
+
* job, torn down the moment it exits - without ever holding the Docker
|
|
746
|
+
* socket. The app picks what to run; the worker dictates how it runs
|
|
747
|
+
* (hardening, network, lifecycle). The canonical consumer is a builder
|
|
748
|
+
* running attacker-controlled `RUN` steps.
|
|
749
|
+
*
|
|
750
|
+
* Resolves to `{ socket, scratch, artifacts }`, three mounts Home fills per-app
|
|
751
|
+
* at eval (`/opt/realm/worker/spawn/sock/<appId>` + `data/<appId>/scratch` +
|
|
752
|
+
* `data/<appId>/artifacts`): the broker socket DIRECTORY to mount (connect to
|
|
753
|
+
* `spawn.sock` inside it), the scratch dir to stage a build context in (input),
|
|
754
|
+
* and the artifacts dir to read a build's output back from. Spread each into
|
|
755
|
+
* `volumes`. `socket` is `readOnly: true` and can't be overridden — connecting
|
|
756
|
+
* to the broker needs no write access to the directory holding it; `scratch`
|
|
757
|
+
* and `artifacts` are the app's own storage and stay writable. All three are
|
|
758
|
+
* built per-app by this kind's `resolve` hook (mirrors `home.apiAccess`).
|
|
759
|
+
* The grant exempts all three resolved paths from the absolute-mount gate,
|
|
760
|
+
* so a bare `host.spawn()` is enough to wire them. There is no network:
|
|
761
|
+
* the box attaches to the untrusted-zone DMZ broker-side, and no app service
|
|
762
|
+
* may sit on that DMZ.
|
|
763
|
+
*
|
|
764
|
+
* @see https://yourrealm.eu/docs/realm-ts/host/spawn/
|
|
765
|
+
*/
|
|
766
|
+
export const spawn = hostable(
|
|
767
|
+
"host.spawn",
|
|
768
|
+
z.literal(true).default(true),
|
|
769
|
+
{
|
|
770
|
+
// The "use" of host.spawn is implicit — mounting the socket/scratch/
|
|
771
|
+
// artifacts dirs falls out of the grant being in place; the resolved
|
|
772
|
+
// paths are exempted from the absolute-mount gate via `collectAllowed`
|
|
773
|
+
// in the Home eval graph.
|
|
774
|
+
iterateUses: () => [],
|
|
775
|
+
// Type-level only; every real value comes from `resolve`, since all three
|
|
776
|
+
// paths are per-app and the grant carries only presence.
|
|
777
|
+
toValue: (): SpawnValue => SPAWN_PLACEHOLDER,
|
|
778
|
+
// `socket` is the per-app DIRECTORY the app mounts (connect to
|
|
779
|
+
// `spawn.sock` inside); the wire carries the socket file inside it (the
|
|
780
|
+
// path the worker binds). scratch/artifacts are dir mounts.
|
|
781
|
+
resolve: ({ ctx }): SpawnValue => spawnMounts(ctx.appId),
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
name: "Spawn disposable containers",
|
|
785
|
+
phrase: "spawn disposable hardened containers on the worker",
|
|
786
|
+
helpText:
|
|
787
|
+
"This app can start one-shot, fully-hardened containers on your server (e.g. to run builds). The worker forces the hardening, network, and lifecycle; the app never gets the Docker socket.",
|
|
788
|
+
icon: "CubeIcon",
|
|
789
|
+
danger: true,
|
|
790
|
+
appKinds: "both",
|
|
791
|
+
},
|
|
792
|
+
);
|