@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,540 @@
|
|
|
1
|
+
// One uplink session: a WebSocket, a completed Noise XX handshake, the
|
|
2
|
+
// transport cipher pair, and a `Mux` + `Rpc` riding the plaintext.
|
|
3
|
+
//
|
|
4
|
+
// Framing is one Noise message per binary WebSocket message. A text frame is
|
|
5
|
+
// a protocol error; `permessage-deflate` is never negotiated, because Deno's
|
|
6
|
+
// `WebSocket` does not offer it.
|
|
7
|
+
//
|
|
8
|
+
// The session owns no schedule beyond the handshake deadline and the
|
|
9
|
+
// backpressure poll. `todo/ideas/uplink.md` § Session lifecycle puts the ping
|
|
10
|
+
// cadence and the reconnect on the worker's loop and the idle deadline on
|
|
11
|
+
// Home's session registry; this layer only exposes `ping()` and
|
|
12
|
+
// `lastReceivedAt` for them to read.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type CipherState,
|
|
16
|
+
HandshakeState,
|
|
17
|
+
type Keypair,
|
|
18
|
+
NoiseError,
|
|
19
|
+
type TransportPair,
|
|
20
|
+
} from "@yourrealm/noise";
|
|
21
|
+
import {
|
|
22
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
23
|
+
NOISE_MAX_PAYLOAD,
|
|
24
|
+
SOCKET_HIGH_WATER,
|
|
25
|
+
SOCKET_INBOUND_CAP,
|
|
26
|
+
UPLINK_PROLOGUE,
|
|
27
|
+
} from "./constants.ts";
|
|
28
|
+
import { HomeKeyMismatch, SessionClosed, UplinkProtocolError } from "./errors.ts";
|
|
29
|
+
import { EMPTY_PAYLOAD } from "./frame.ts";
|
|
30
|
+
import { type ByteDuplex, type Limits, Mux, type MuxSide } from "./mux.ts";
|
|
31
|
+
import { Rpc, type RpcHandler } from "./rpc.ts";
|
|
32
|
+
|
|
33
|
+
/** Noise transport messages carry no associated data; the key pair is the AD. */
|
|
34
|
+
const EMPTY_AD = EMPTY_PAYLOAD;
|
|
35
|
+
|
|
36
|
+
const CLOSE_NORMAL = 1000;
|
|
37
|
+
const CLOSE_PROTOCOL = 1002;
|
|
38
|
+
/** 1008 (policy violation) is what a Noise failure closes with. */
|
|
39
|
+
const CLOSE_POLICY = 1008;
|
|
40
|
+
|
|
41
|
+
/** A close reason is capped at 123 UTF-8 bytes; stay well inside it. */
|
|
42
|
+
const MAX_CLOSE_REASON_CHARS = 100;
|
|
43
|
+
|
|
44
|
+
/** There is no drain event in the WHATWG WebSocket API, so the gate polls. */
|
|
45
|
+
const DRAIN_POLL_MS = 5;
|
|
46
|
+
|
|
47
|
+
/** `setTimeout` is a `number` under Deno's lib and a `Timeout` under node's. */
|
|
48
|
+
type TimerId = ReturnType<typeof setTimeout>;
|
|
49
|
+
|
|
50
|
+
export interface SessionOptions {
|
|
51
|
+
/** Defaults to `UPLINK_PROLOGUE`. Only a test has reason to change it. */
|
|
52
|
+
prologue?: Uint8Array;
|
|
53
|
+
handshakeTimeoutMs?: number;
|
|
54
|
+
limits?: Partial<Limits>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UplinkSession {
|
|
58
|
+
/** The peer's Noise static key. Home looks it up; the worker pinned it. */
|
|
59
|
+
readonly peerPublicKey: Uint8Array;
|
|
60
|
+
readonly rpc: Rpc;
|
|
61
|
+
/** The close reason. Never rejects. */
|
|
62
|
+
readonly closed: Promise<string>;
|
|
63
|
+
/** ms epoch of the last inbound frame, for whoever owns the idle deadline. */
|
|
64
|
+
readonly lastReceivedAt: number;
|
|
65
|
+
/** Resolves on the matching PONG. The caller owns the deadline. */
|
|
66
|
+
ping(): Promise<void>;
|
|
67
|
+
close(reason: string): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Worker side. Dials, handshakes as initiator, pins Home's static key. */
|
|
71
|
+
export async function connectUplink(
|
|
72
|
+
opts: {
|
|
73
|
+
url: string;
|
|
74
|
+
identity: Keypair;
|
|
75
|
+
homePublicKey: Uint8Array;
|
|
76
|
+
handlers: Record<string, RpcHandler>;
|
|
77
|
+
} & SessionOptions,
|
|
78
|
+
): Promise<UplinkSession> {
|
|
79
|
+
const socket = new WebSocket(opts.url);
|
|
80
|
+
const feed = new SocketFeed(socket);
|
|
81
|
+
try {
|
|
82
|
+
const { peer, transport } = await withDeadline(
|
|
83
|
+
opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS,
|
|
84
|
+
async () => {
|
|
85
|
+
await feed.opened;
|
|
86
|
+
const handshake = new HandshakeState({
|
|
87
|
+
initiator: true,
|
|
88
|
+
prologue: opts.prologue ?? UPLINK_PROLOGUE,
|
|
89
|
+
staticKeypair: opts.identity,
|
|
90
|
+
});
|
|
91
|
+
sendBinary(socket, handshake.writeMessage(EMPTY_AD));
|
|
92
|
+
handshake.readMessage(await feed.next());
|
|
93
|
+
// Message 2 carries Home's static. Compare before message 3, which is
|
|
94
|
+
// what keeps the worker's own static off an impostor's wire.
|
|
95
|
+
const peer = requirePeerKey(handshake);
|
|
96
|
+
if (!sameKey(peer, opts.homePublicKey)) {
|
|
97
|
+
throw new HomeKeyMismatch(opts.homePublicKey, peer);
|
|
98
|
+
}
|
|
99
|
+
sendBinary(socket, handshake.writeMessage(EMPTY_AD));
|
|
100
|
+
return { peer, transport: handshake.split() };
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
return new Session(socket, feed, transport, peer, "initiator", opts.handlers, opts.limits);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
closeSocket(socket, failureCode(error), asError(error).message);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Home side. Takes an already-upgraded socket and handshakes as responder.
|
|
112
|
+
*
|
|
113
|
+
* Called from inside the upgrade handler, which returns its response without
|
|
114
|
+
* waiting, so the caller owns this promise's rejection — a failed handshake
|
|
115
|
+
* has already closed the socket by the time it rejects.
|
|
116
|
+
*/
|
|
117
|
+
export async function acceptUplink(
|
|
118
|
+
opts: {
|
|
119
|
+
socket: WebSocket;
|
|
120
|
+
identity: Keypair;
|
|
121
|
+
/** Chosen once the worker's static is known: an unknown key gets the
|
|
122
|
+
* enroll-only map (`todo/ideas/uplink.md` § 2 Noise). */
|
|
123
|
+
handlers: (peer: Uint8Array) => Record<string, RpcHandler>;
|
|
124
|
+
} & SessionOptions,
|
|
125
|
+
): Promise<UplinkSession> {
|
|
126
|
+
const socket = opts.socket;
|
|
127
|
+
const feed = new SocketFeed(socket);
|
|
128
|
+
try {
|
|
129
|
+
const { peer, transport } = await withDeadline(
|
|
130
|
+
opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS,
|
|
131
|
+
async () => {
|
|
132
|
+
await feed.opened;
|
|
133
|
+
const handshake = new HandshakeState({
|
|
134
|
+
initiator: false,
|
|
135
|
+
prologue: opts.prologue ?? UPLINK_PROLOGUE,
|
|
136
|
+
staticKeypair: opts.identity,
|
|
137
|
+
});
|
|
138
|
+
handshake.readMessage(await feed.next());
|
|
139
|
+
sendBinary(socket, handshake.writeMessage(EMPTY_AD));
|
|
140
|
+
handshake.readMessage(await feed.next());
|
|
141
|
+
return { peer: requirePeerKey(handshake), transport: handshake.split() };
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
return new Session(
|
|
145
|
+
socket,
|
|
146
|
+
feed,
|
|
147
|
+
transport,
|
|
148
|
+
peer,
|
|
149
|
+
"responder",
|
|
150
|
+
opts.handlers(peer),
|
|
151
|
+
opts.limits,
|
|
152
|
+
);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
closeSocket(socket, failureCode(error), asError(error).message);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class Session implements UplinkSession {
|
|
160
|
+
readonly peerPublicKey: Uint8Array;
|
|
161
|
+
readonly rpc: Rpc;
|
|
162
|
+
readonly closed: Promise<string>;
|
|
163
|
+
|
|
164
|
+
readonly #socket: WebSocket;
|
|
165
|
+
readonly #feed: SocketFeed;
|
|
166
|
+
readonly #mux: Mux;
|
|
167
|
+
readonly #closedResolve: (reason: string) => void;
|
|
168
|
+
/** Every armed drain poll, so `closed` can never resolve over a live timer. */
|
|
169
|
+
readonly #polls = new Map<TimerId, () => void>();
|
|
170
|
+
#lastReceivedAt = Date.now();
|
|
171
|
+
#closeReason: string | undefined;
|
|
172
|
+
|
|
173
|
+
constructor(
|
|
174
|
+
socket: WebSocket,
|
|
175
|
+
feed: SocketFeed,
|
|
176
|
+
transport: TransportPair,
|
|
177
|
+
peer: Uint8Array,
|
|
178
|
+
side: MuxSide,
|
|
179
|
+
handlers: Record<string, RpcHandler>,
|
|
180
|
+
limits: Partial<Limits> | undefined,
|
|
181
|
+
) {
|
|
182
|
+
this.peerPublicKey = peer;
|
|
183
|
+
this.#socket = socket;
|
|
184
|
+
this.#feed = feed;
|
|
185
|
+
|
|
186
|
+
let resolveClosed: (reason: string) => void = () => {};
|
|
187
|
+
this.closed = new Promise<string>((resolve) => {
|
|
188
|
+
resolveClosed = resolve;
|
|
189
|
+
});
|
|
190
|
+
this.#closedResolve = resolveClosed;
|
|
191
|
+
|
|
192
|
+
const duplex: ByteDuplex = {
|
|
193
|
+
readable: this.#makeReadable(transport.recv),
|
|
194
|
+
writable: this.#makeWritable(transport.send),
|
|
195
|
+
};
|
|
196
|
+
this.#mux = new Mux(duplex, { side, ...limits });
|
|
197
|
+
this.rpc = new Rpc(this.#mux, handlers);
|
|
198
|
+
|
|
199
|
+
// The socket dying, and the mux giving up on the transport, are the two
|
|
200
|
+
// ends the session cannot see from inside a stream.
|
|
201
|
+
feed.onEnd = (reason, code) => {
|
|
202
|
+
this.#shutdown(reason, code);
|
|
203
|
+
};
|
|
204
|
+
void this.#mux.closed.then((reason) => {
|
|
205
|
+
this.#shutdown(reason, CLOSE_PROTOCOL);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get lastReceivedAt(): number {
|
|
210
|
+
return this.#lastReceivedAt;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
ping(): Promise<void> {
|
|
214
|
+
return this.#mux.ping();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
close(reason: string): void {
|
|
218
|
+
this.#shutdown(reason, CLOSE_NORMAL);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- transport -------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
// One binary WebSocket message per Noise message. The mux writes one frame
|
|
224
|
+
// per `write()`, at most 7 + 16384 bytes, so the split below is a guard
|
|
225
|
+
// rather than a hot path.
|
|
226
|
+
#makeWritable(send: CipherState): WritableStream<Uint8Array> {
|
|
227
|
+
return new WritableStream<Uint8Array>({
|
|
228
|
+
write: async (chunk) => {
|
|
229
|
+
for (let offset = 0; offset < chunk.length; offset += NOISE_MAX_PAYLOAD) {
|
|
230
|
+
const end = Math.min(offset + NOISE_MAX_PAYLOAD, chunk.length);
|
|
231
|
+
await this.#awaitDrain();
|
|
232
|
+
const reason = this.#closeReason;
|
|
233
|
+
if (reason !== undefined) throw new SessionClosed(reason);
|
|
234
|
+
try {
|
|
235
|
+
sendBinary(this.#socket, send.encryptWithAd(EMPTY_AD, chunk.subarray(offset, end)));
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const error = asError(e);
|
|
238
|
+
this.#shutdown(error.message, failureCode(error));
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
// The mux aborts the writable and cancels the readable on its own
|
|
244
|
+
// shutdown; both mean the same thing here.
|
|
245
|
+
abort: (reason) => {
|
|
246
|
+
this.#shutdown(reasonText(reason), CLOSE_PROTOCOL);
|
|
247
|
+
},
|
|
248
|
+
close: () => {
|
|
249
|
+
this.#shutdown("mux closed the transport", CLOSE_NORMAL);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A zero high-water mark is the inbound backpressure: one message is
|
|
255
|
+
// decrypted per outstanding read, so nothing is decrypted ahead of the mux.
|
|
256
|
+
// What bounds the peer beyond that is the mux's own in-flight cap, which a
|
|
257
|
+
// flooding peer trips as a protocol error.
|
|
258
|
+
#makeReadable(recv: CipherState): ReadableStream<Uint8Array> {
|
|
259
|
+
return new ReadableStream<Uint8Array>(
|
|
260
|
+
{
|
|
261
|
+
pull: async (controller) => {
|
|
262
|
+
const message = await this.#feed.messages.next();
|
|
263
|
+
if (message === undefined) {
|
|
264
|
+
controller.close();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.#lastReceivedAt = Date.now();
|
|
268
|
+
let plaintext: Uint8Array;
|
|
269
|
+
try {
|
|
270
|
+
plaintext = recv.decryptWithAd(EMPTY_AD, message);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
const error = asError(e);
|
|
273
|
+
this.#shutdown(error.message, failureCode(error));
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
if (plaintext.length > 0) controller.enqueue(plaintext);
|
|
277
|
+
},
|
|
278
|
+
cancel: (reason) => {
|
|
279
|
+
this.#shutdown(reasonText(reason), CLOSE_PROTOCOL);
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{ highWaterMark: 0 },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `WebSocket.send` buffers without bound, so the mux's `write()` waits here
|
|
287
|
+
// until the socket has drained. That is what makes a resolved `write()` mean
|
|
288
|
+
// "handed off", which is what keeps control frames ahead of a body.
|
|
289
|
+
async #awaitDrain(): Promise<void> {
|
|
290
|
+
while (this.#closeReason === undefined && this.#socket.bufferedAmount > SOCKET_HIGH_WATER) {
|
|
291
|
+
await this.#poll();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#poll(): Promise<void> {
|
|
296
|
+
return new Promise<void>((resolve) => {
|
|
297
|
+
const id = setTimeout(() => {
|
|
298
|
+
this.#polls.delete(id);
|
|
299
|
+
resolve();
|
|
300
|
+
}, DRAIN_POLL_MS);
|
|
301
|
+
this.#polls.set(id, resolve);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- teardown --------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
#shutdown(reason: string, code: number): void {
|
|
308
|
+
if (this.#closeReason !== undefined) return;
|
|
309
|
+
this.#closeReason = reason;
|
|
310
|
+
for (const [id, resolve] of this.#polls) {
|
|
311
|
+
clearTimeout(id);
|
|
312
|
+
resolve();
|
|
313
|
+
}
|
|
314
|
+
this.#polls.clear();
|
|
315
|
+
this.#mux.close(reason);
|
|
316
|
+
closeSocket(this.#socket, code, reason);
|
|
317
|
+
this.#closedResolve(reason);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The socket's events, turned into one ordered queue of whole messages plus
|
|
323
|
+
* an `opened` promise. Listeners are attached before the handshake so nothing
|
|
324
|
+
* that arrives between the upgrade and the first read is lost.
|
|
325
|
+
*/
|
|
326
|
+
class SocketFeed {
|
|
327
|
+
readonly messages = new MessageQueue();
|
|
328
|
+
readonly opened: Promise<void>;
|
|
329
|
+
/** Set once the session exists; before that, `messages` carries the failure. */
|
|
330
|
+
onEnd: ((reason: string, code: number) => void) | undefined;
|
|
331
|
+
|
|
332
|
+
#open = false;
|
|
333
|
+
|
|
334
|
+
constructor(socket: WebSocket) {
|
|
335
|
+
socket.binaryType = "arraybuffer";
|
|
336
|
+
let resolveOpen: () => void = () => {};
|
|
337
|
+
let rejectOpen: (error: Error) => void = () => {};
|
|
338
|
+
this.opened = new Promise<void>((resolve, reject) => {
|
|
339
|
+
resolveOpen = resolve;
|
|
340
|
+
rejectOpen = reject;
|
|
341
|
+
});
|
|
342
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
343
|
+
this.#open = true;
|
|
344
|
+
resolveOpen();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
socket.addEventListener("open", () => {
|
|
348
|
+
this.#open = true;
|
|
349
|
+
resolveOpen();
|
|
350
|
+
});
|
|
351
|
+
socket.addEventListener("message", (event: MessageEvent) => {
|
|
352
|
+
const data: unknown = event.data;
|
|
353
|
+
if (data instanceof ArrayBuffer) {
|
|
354
|
+
this.messages.push(new Uint8Array(data));
|
|
355
|
+
if (this.messages.queuedBytes > SOCKET_INBOUND_CAP) {
|
|
356
|
+
const error = new UplinkProtocolError("the peer outran the inbound queue");
|
|
357
|
+
this.#end(error.message, error, CLOSE_PROTOCOL);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// A text frame carries no Noise message, so there is nothing to do with
|
|
362
|
+
// it but close: `todo/ideas/uplink.md` § 1 Byte stream.
|
|
363
|
+
const error = new UplinkProtocolError("the peer sent a text frame");
|
|
364
|
+
this.#end(error.message, error, CLOSE_PROTOCOL);
|
|
365
|
+
});
|
|
366
|
+
socket.addEventListener("error", () => {
|
|
367
|
+
// The event carries nothing useful; `close` follows with the detail.
|
|
368
|
+
this.#end("socket error", new SessionClosed("socket error"), CLOSE_NORMAL);
|
|
369
|
+
});
|
|
370
|
+
socket.addEventListener("close", (event: CloseEvent) => {
|
|
371
|
+
const detail = event.reason === "" ? `code ${event.code}` : event.reason;
|
|
372
|
+
const reason = `socket closed: ${detail}`;
|
|
373
|
+
if (!this.#open) rejectOpen(new SessionClosed(reason));
|
|
374
|
+
this.messages.end();
|
|
375
|
+
this.onEnd?.(reason, CLOSE_NORMAL);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The next whole message, or a rejection if the socket ended first. */
|
|
380
|
+
async next(): Promise<Uint8Array> {
|
|
381
|
+
const message = await this.messages.next();
|
|
382
|
+
if (message === undefined) {
|
|
383
|
+
throw new UplinkProtocolError("the socket closed mid-handshake");
|
|
384
|
+
}
|
|
385
|
+
return message;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#end(reason: string, error: Error, code: number): void {
|
|
389
|
+
this.messages.fail(error);
|
|
390
|
+
this.onEnd?.(reason, code);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Whole messages, in order, handed out one at a time. `undefined` is EOF. */
|
|
395
|
+
class MessageQueue {
|
|
396
|
+
readonly #queue: Uint8Array[] = [];
|
|
397
|
+
#queuedBytes = 0;
|
|
398
|
+
#pending:
|
|
399
|
+
| { resolve: (value: Uint8Array | undefined) => void; reject: (error: Error) => void }
|
|
400
|
+
| undefined;
|
|
401
|
+
#error: Error | undefined;
|
|
402
|
+
#ended = false;
|
|
403
|
+
|
|
404
|
+
/** Bytes waiting for a `next()`, the number the inbound cap bounds. */
|
|
405
|
+
get queuedBytes(): number {
|
|
406
|
+
return this.#queuedBytes;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
push(bytes: Uint8Array): void {
|
|
410
|
+
const pending = this.#pending;
|
|
411
|
+
if (pending === undefined) {
|
|
412
|
+
this.#queue.push(bytes);
|
|
413
|
+
this.#queuedBytes += bytes.length;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.#pending = undefined;
|
|
417
|
+
pending.resolve(bytes);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
end(): void {
|
|
421
|
+
if (this.#ended) return;
|
|
422
|
+
this.#ended = true;
|
|
423
|
+
// A pending waiter means the queue is empty, so nothing is dropped.
|
|
424
|
+
const pending = this.#pending;
|
|
425
|
+
if (pending !== undefined) {
|
|
426
|
+
this.#pending = undefined;
|
|
427
|
+
pending.resolve(undefined);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
fail(error: Error): void {
|
|
432
|
+
this.#error ??= error;
|
|
433
|
+
// Whatever was queued belongs to a session that is closing.
|
|
434
|
+
this.#queue.length = 0;
|
|
435
|
+
this.#queuedBytes = 0;
|
|
436
|
+
const pending = this.#pending;
|
|
437
|
+
if (pending !== undefined) {
|
|
438
|
+
this.#pending = undefined;
|
|
439
|
+
pending.reject(error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
next(): Promise<Uint8Array | undefined> {
|
|
444
|
+
const head = this.#queue.shift();
|
|
445
|
+
if (head !== undefined) {
|
|
446
|
+
this.#queuedBytes -= head.length;
|
|
447
|
+
return Promise.resolve(head);
|
|
448
|
+
}
|
|
449
|
+
if (this.#error !== undefined) return Promise.reject(this.#error);
|
|
450
|
+
if (this.#ended) return Promise.resolve(undefined);
|
|
451
|
+
return new Promise<Uint8Array | undefined>((resolve, reject) => {
|
|
452
|
+
this.#pending = { resolve, reject };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- helpers ------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
// The whole handshake, socket open included, under one deadline. `run` is
|
|
460
|
+
// caught separately so its own later failure is not an unhandled rejection
|
|
461
|
+
// once the race has already reported the expiry.
|
|
462
|
+
async function withDeadline<T>(ms: number, run: () => Promise<T>): Promise<T> {
|
|
463
|
+
const running = run();
|
|
464
|
+
running.catch(() => {});
|
|
465
|
+
let timer: TimerId | undefined;
|
|
466
|
+
const expiry = new Promise<never>((_, reject) => {
|
|
467
|
+
timer = setTimeout(() => {
|
|
468
|
+
reject(new UplinkProtocolError(`the handshake did not finish inside ${ms}ms`));
|
|
469
|
+
}, ms);
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
return await Promise.race([running, expiry]);
|
|
473
|
+
} finally {
|
|
474
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function requirePeerKey(handshake: HandshakeState): Uint8Array {
|
|
479
|
+
const peer = handshake.remoteStaticPublicKey;
|
|
480
|
+
// XX transmits both statics, so this is unreachable on a message the
|
|
481
|
+
// handshake accepted; it is here so the type is not an assertion.
|
|
482
|
+
if (peer === undefined) throw new UplinkProtocolError("the handshake carried no static key");
|
|
483
|
+
return peer;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Public keys, so there is nothing to leak by timing.
|
|
487
|
+
function sameKey(a: Uint8Array, b: Uint8Array): boolean {
|
|
488
|
+
if (a.length !== b.length) return false;
|
|
489
|
+
for (let i = 0; i < a.length; i++) {
|
|
490
|
+
if (a[i] !== b[i]) return false;
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function failureCode(error: unknown): number {
|
|
496
|
+
// A Noise failure — a bad tag, a rejected key, the pin — is 1008; anything
|
|
497
|
+
// else the session refuses is framing, which is 1002.
|
|
498
|
+
return error instanceof NoiseError || error instanceof HomeKeyMismatch
|
|
499
|
+
? CLOSE_POLICY
|
|
500
|
+
: CLOSE_PROTOCOL;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// `send` is typed for an `ArrayBuffer`-backed view; every Noise message is a
|
|
504
|
+
// fresh allocation, so this is a type-level fact rather than a claim.
|
|
505
|
+
function sendBinary(socket: WebSocket, message: Uint8Array): void {
|
|
506
|
+
socket.send(message as Uint8Array<ArrayBuffer>);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Both fallbacks earn their place. The WHATWG API lets application code send
|
|
510
|
+
// only 1000 or 3000-4999, which Deno enforces on a *client* socket while
|
|
511
|
+
// allowing the reserved codes on an upgraded server one — so the worker's end
|
|
512
|
+
// cannot spell 1002 or 1008 and falls back to 1000, where the reason string
|
|
513
|
+
// still carries the detail. The last resort covers a reason that measured
|
|
514
|
+
// longer in UTF-8 than its character count suggested.
|
|
515
|
+
function closeSocket(socket: WebSocket, code: number, reason: string): void {
|
|
516
|
+
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) return;
|
|
517
|
+
const text = reason.slice(0, MAX_CLOSE_REASON_CHARS);
|
|
518
|
+
const attempts = [
|
|
519
|
+
() => socket.close(code, text),
|
|
520
|
+
() => socket.close(CLOSE_NORMAL, text),
|
|
521
|
+
() => socket.close(),
|
|
522
|
+
];
|
|
523
|
+
for (const attempt of attempts) {
|
|
524
|
+
try {
|
|
525
|
+
attempt();
|
|
526
|
+
return;
|
|
527
|
+
} catch {
|
|
528
|
+
/* try the next spelling */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function asError(value: unknown): Error {
|
|
534
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function reasonText(reason: unknown): string {
|
|
538
|
+
if (reason === undefined || reason === null) return "transport closed";
|
|
539
|
+
return reason instanceof Error ? reason.message : String(reason);
|
|
540
|
+
}
|