@netnodeag/kraftwerk 0.70.0 → 0.71.1
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/README.md +39 -0
- package/dist/cli/daemon.js +6 -0
- package/dist/cli/doctor.js +3 -0
- package/dist/cli/kraftwerk.js +2 -0
- package/dist/cli/remote.d.ts +2 -0
- package/dist/cli/remote.js +89 -0
- package/dist/client/relay.d.ts +149 -0
- package/dist/client/relay.js +318 -0
- package/dist/client/routes.d.ts +2 -0
- package/dist/client/routes.js +2 -0
- package/dist/server/api/devices.d.ts +28 -2
- package/dist/server/api/devices.js +39 -5
- package/dist/server/api/index.d.ts +28 -2
- package/dist/server/relay.d.ts +77 -0
- package/dist/server/relay.js +321 -0
- package/dist/server/ws.js +3 -0
- package/package.json +7 -2
- package/web/dist/assets/{dist-DPXNFIR0.js → dist-4mt46bwa.js} +1 -1
- package/web/dist/assets/{dist-BeHtCIB0.js → dist-5bpaJOp6.js} +1 -1
- package/web/dist/assets/{dist-5v0UmV-F.js → dist-8As2tNSv.js} +1 -1
- package/web/dist/assets/{dist-B5BBuAK8.js → dist-BFetH-PC.js} +1 -1
- package/web/dist/assets/{dist-DGfHUaeU.js → dist-BcEOC-ps.js} +1 -1
- package/web/dist/assets/{dist-CxMl8Xeq.js → dist-BmsKU6ix.js} +1 -1
- package/web/dist/assets/{dist-DOUkQhjz.js → dist-CANEcq7F.js} +1 -1
- package/web/dist/assets/dist-CWrRm3RK.js +1 -0
- package/web/dist/assets/{dist-CkKJVLbX.js → dist-Coej_0FF.js} +1 -1
- package/web/dist/assets/{dist-D4wB4wkJ.js → dist-Du3myOTO.js} +1 -1
- package/web/dist/assets/{dist-B-eaMFbi.js → dist-FeMkWwZl.js} +1 -1
- package/web/dist/assets/{editor-oLm1tYCp.js → editor-B3am-j9U.js} +3 -3
- package/web/dist/assets/index-C8DF24q1.css +2 -0
- package/web/dist/assets/{index-V2VT7ZQP.js → index-CrBIiOwc.js} +26 -24
- package/web/dist/index.html +2 -2
- package/web/dist-remote/assets/index-2nAI1HTV.js +13 -0
- package/web/dist-remote/index.html +16 -0
- package/web/dist-remote/sw.js +1 -0
- package/web/dist/assets/dist-1EXSVa5k.js +0 -1
- package/web/dist/assets/index-CrD3grQE.css +0 -2
package/README.md
CHANGED
|
@@ -108,6 +108,15 @@ The phone now sees your workspaces, each at `/w/<slug>/` on that address,
|
|
|
108
108
|
and the workspace menu switches between them. One pairing covers all of them
|
|
109
109
|
(see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
|
|
110
110
|
|
|
111
|
+
**From anywhere** (mobile data, another Wi-Fi), with no port opened:
|
|
112
|
+
|
|
113
|
+
1. `kraftwerk remote on`. The daemon connects to the kraftwerk relay.
|
|
114
|
+
2. `kraftwerk remote pair`, or settings → devices → pair a device, which shows
|
|
115
|
+
a QR code. Open the link (scan the code) on the phone and tap *Pair*.
|
|
116
|
+
|
|
117
|
+
The phone runs the same web UI, end to end encrypted through the relay
|
|
118
|
+
(see [From anywhere: the relay](#from-anywhere-the-relay)).
|
|
119
|
+
|
|
111
120
|
**What to do first in the inspector.** Talk to **Ralv**, the workspace's
|
|
112
121
|
general chat, in plain words: it knows what is in the workspace and helps
|
|
113
122
|
set up the rest. From there:
|
|
@@ -239,6 +248,7 @@ kraftwerk daemon # one kraftwerk for every open workspace
|
|
|
239
248
|
kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); --lan, --port; `daemon uninstall` removes it
|
|
240
249
|
kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
|
|
241
250
|
kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
|
|
251
|
+
kraftwerk remote on|off|pair # reach this machine from anywhere through the relay, end to end encrypted
|
|
242
252
|
kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
|
|
243
253
|
kraftwerk trash # what was deleted; trash restore|purge <id>, trash empty
|
|
244
254
|
kraftwerk journal <agent> ["<entry>"] # an agent's memory across sessions: print it, add a line (--kind)
|
|
@@ -417,6 +427,35 @@ Behind a reverse proxy on the same machine, the proxy must send
|
|
|
417
427
|
other device. If it cannot, set `KRAFTWERK_UI_REQUIRE_PAIRING=1`, which
|
|
418
428
|
makes every request pair, this machine's own included.
|
|
419
429
|
|
|
430
|
+
### From anywhere: the relay
|
|
431
|
+
|
|
432
|
+
`kraftwerk remote on` makes the daemon hold a connection to the kraftwerk
|
|
433
|
+
relay (kraftwerk cloud; `--url` for another one). A paired device reaches the
|
|
434
|
+
machine through it from any network: no port forwarding, no tunnel, and the
|
|
435
|
+
machine needs no public address. `kraftwerk remote pair` (or settings →
|
|
436
|
+
devices → pair a device, with a QR code) gives a link to the cloud's remote
|
|
437
|
+
page. It carries this machine's key and a one-time code in its `#` part,
|
|
438
|
+
which never reaches a server. The phone pairs, then runs the whole web UI
|
|
439
|
+
through the relay. `kraftwerk remote off` disconnects, `kraftwerk remote`
|
|
440
|
+
shows the state, and settings → devices has the same switch.
|
|
441
|
+
|
|
442
|
+
Everything between the phone and the machine is end to end encrypted:
|
|
443
|
+
|
|
444
|
+
- X25519 key agreement on the machine's long-lived key plus a fresh key on
|
|
445
|
+
each side per connection, then AES-256-GCM with counters as nonces.
|
|
446
|
+
- The relay sees which machine, when and how much, never what. It cannot
|
|
447
|
+
forge or replay a message.
|
|
448
|
+
- The phone checks the machine's key (the fingerprint `kraftwerk remote` shows
|
|
449
|
+
is on the pairing page), so nobody else can answer in its place.
|
|
450
|
+
- Through the relay, a request counts as a network device's: it needs the
|
|
451
|
+
device's pairing like on your Wi-Fi, and unpairing ends it at once.
|
|
452
|
+
- One trust remains: the remote page's code comes from the cloud that serves
|
|
453
|
+
it. A native app will remove that.
|
|
454
|
+
|
|
455
|
+
The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
|
|
456
|
+
(owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
|
|
457
|
+
must be awake.
|
|
458
|
+
|
|
420
459
|
For a client of your own, `@netnodeag/kraftwerk/client` takes the token:
|
|
421
460
|
`createClient({ baseUrl, token })`.
|
|
422
461
|
|
package/dist/cli/daemon.js
CHANGED
|
@@ -2,7 +2,9 @@ import chalk from "chalk";
|
|
|
2
2
|
import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
|
|
3
3
|
import { tildify } from "../core/instances.js";
|
|
4
4
|
import { networkUrls } from "../server/bind.js";
|
|
5
|
+
import { applyRelay } from "../server/relay.js";
|
|
5
6
|
import { startHub } from "../server/server.js";
|
|
7
|
+
import { getPkgVersion } from "../server/version.js";
|
|
6
8
|
import { ensureBuilt, supervise } from "./ui.js";
|
|
7
9
|
import { installDaemon, uninstallDaemon } from "./daemon-login.js";
|
|
8
10
|
/**
|
|
@@ -45,6 +47,10 @@ export async function runDaemon(opts) {
|
|
|
45
47
|
console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
|
|
46
48
|
if (!hub.list().length)
|
|
47
49
|
console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
|
|
50
|
+
// The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
|
|
51
|
+
const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
|
|
52
|
+
if (relay.state !== "off")
|
|
53
|
+
console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
|
|
48
54
|
const lan = networkUrls(hub.port);
|
|
49
55
|
if (lan.length)
|
|
50
56
|
console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
|
package/dist/cli/doctor.js
CHANGED
|
@@ -11,6 +11,7 @@ import { listDevices } from "../core/devices.js";
|
|
|
11
11
|
import { slugClashes } from "../core/instances.js";
|
|
12
12
|
import { findDaemon } from "../core/daemon.js";
|
|
13
13
|
import { daemonLoginItem } from "./daemon-login.js";
|
|
14
|
+
import { readRelaySettings } from "../server/relay.js";
|
|
14
15
|
const ICONS = {
|
|
15
16
|
ok: chalk.green("✔"),
|
|
16
17
|
warn: chalk.yellow("⚠"),
|
|
@@ -222,6 +223,8 @@ export async function runDoctor(cwd) {
|
|
|
222
223
|
: daemon
|
|
223
224
|
? undefined
|
|
224
225
|
: "`kraftwerk daemon` serves every workspace in one process");
|
|
226
|
+
const relay = await readRelaySettings();
|
|
227
|
+
report("info", relay?.enabled ? `remote access on through ${relay.url}` : "remote access off", relay?.enabled ? (daemon ? "`kraftwerk remote` shows whether the daemon is connected" : "the daemon connects it once it runs") : "`kraftwerk remote on` reaches this machine from anywhere, end to end encrypted");
|
|
225
228
|
// Legacy: what older versions left behind, and what still runs or is installed at one.
|
|
226
229
|
for (const f of await legacyFindings(project, pkg.version))
|
|
227
230
|
report(f.level, f.label, f.detail);
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerVibeableCommands } from "./vibeables.js";
|
|
|
19
19
|
import { registerTrashCommands } from "./trash.js";
|
|
20
20
|
import { registerDevicesCommands } from "./devices.js";
|
|
21
21
|
import { registerDaemonCommand } from "./daemon.js";
|
|
22
|
+
import { registerRemoteCommand } from "./remote.js";
|
|
22
23
|
import { registerJournalCommands } from "./journal.js";
|
|
23
24
|
import { applyDotenv } from "../dotenv.js";
|
|
24
25
|
import { resolveWorkspace } from "../config.js";
|
|
@@ -295,6 +296,7 @@ registerVibeableCommands(program);
|
|
|
295
296
|
registerTrashCommands(program);
|
|
296
297
|
registerDevicesCommands(program);
|
|
297
298
|
registerDaemonCommand(program);
|
|
299
|
+
registerRemoteCommand(program);
|
|
298
300
|
registerJournalCommands(program);
|
|
299
301
|
program
|
|
300
302
|
.command("ui")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { createPairCode, PAIR_CODE_TTL_MS } from "../core/devices.js";
|
|
3
|
+
import { findDaemon } from "../core/daemon.js";
|
|
4
|
+
import { keyFingerprint, readRelaySettings, relayEndpoint, remoteLink, setRelay } from "../server/relay.js";
|
|
5
|
+
/** Ask the running daemon (this machine: local trust). */
|
|
6
|
+
async function daemonRelay(method, body) {
|
|
7
|
+
const d = await findDaemon();
|
|
8
|
+
if (!d)
|
|
9
|
+
return null;
|
|
10
|
+
const r = await fetch(`http://127.0.0.1:${d.port}/api/devices/relay`, {
|
|
11
|
+
method,
|
|
12
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
13
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
14
|
+
signal: AbortSignal.timeout(20_000),
|
|
15
|
+
});
|
|
16
|
+
return r.ok ? (await r.json()) : null;
|
|
17
|
+
}
|
|
18
|
+
const describe = (s) => {
|
|
19
|
+
if (!s)
|
|
20
|
+
return chalk.yellow("the daemon is not running — `kraftwerk daemon` (or `kraftwerk daemon install`) connects it");
|
|
21
|
+
if (s.state === "connected")
|
|
22
|
+
return `${chalk.green("connected")}${s.clients ? chalk.dim(` · ${s.clients} device${s.clients === 1 ? "" : "s"} connected now`) : ""}`;
|
|
23
|
+
if (s.state === "connecting")
|
|
24
|
+
return chalk.yellow("connecting…");
|
|
25
|
+
if (s.state === "error")
|
|
26
|
+
return chalk.red(`not connected: ${s.error ?? "unknown error"}`);
|
|
27
|
+
return chalk.dim("off");
|
|
28
|
+
};
|
|
29
|
+
/** Wait briefly for the daemon's link to come up, so `remote on` says whether it worked. */
|
|
30
|
+
async function settle() {
|
|
31
|
+
let s = await daemonRelay("GET").catch(() => null);
|
|
32
|
+
for (let i = 0; i < 20 && s?.state === "connecting"; i++) {
|
|
33
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
34
|
+
s = await daemonRelay("GET").catch(() => null);
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
export function registerRemoteCommand(program) {
|
|
39
|
+
const remote = program
|
|
40
|
+
.command("remote")
|
|
41
|
+
.description("Reach this machine from anywhere through the relay, end to end encrypted: on, off, pair a device")
|
|
42
|
+
.action(async () => {
|
|
43
|
+
const s = await readRelaySettings();
|
|
44
|
+
if (!s?.enabled)
|
|
45
|
+
return void console.log(`${chalk.dim("Remote access is off.")} ${chalk.dim("`kraftwerk remote on` turns it on.")}`);
|
|
46
|
+
console.log(`Remote access ${chalk.bold("on")} through ${chalk.cyan(s.url)} ${chalk.dim(`(machine key ${keyFingerprint(s.publicKey)})`)}`);
|
|
47
|
+
console.log(` ${describe(await daemonRelay("GET").catch(() => null))}`);
|
|
48
|
+
});
|
|
49
|
+
remote
|
|
50
|
+
.command("on")
|
|
51
|
+
.description("Turn remote access on: the daemon connects to the relay")
|
|
52
|
+
.option("--url <url>", "The cloud whose relay to use (default: the kraftwerk cloud)")
|
|
53
|
+
.action(async (opts) => {
|
|
54
|
+
if (opts.url && !/^https?:\/\//.test(opts.url)) {
|
|
55
|
+
console.error(chalk.red("✖ --url is the cloud's address, e.g. https://srv.kraftwerk-cloud.netnode.cloud"));
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
const s = await setRelay(true, opts.url);
|
|
59
|
+
// The daemon reads the file again and connects; without one running, it does when it starts.
|
|
60
|
+
await daemonRelay("POST", { enabled: true, ...(opts.url ? { url: opts.url } : {}) }).catch(() => null);
|
|
61
|
+
const status = await settle();
|
|
62
|
+
console.log(`${chalk.green("✔")} remote access on through ${chalk.cyan(s.url)} ${chalk.dim(`(${relayEndpoint(s.url, "server")})`)}`);
|
|
63
|
+
console.log(` ${describe(status)}`);
|
|
64
|
+
console.log(chalk.dim(" `kraftwerk remote pair` makes a link that pairs a device"));
|
|
65
|
+
});
|
|
66
|
+
remote
|
|
67
|
+
.command("off")
|
|
68
|
+
.description("Turn remote access off: the daemon leaves the relay")
|
|
69
|
+
.action(async () => {
|
|
70
|
+
await setRelay(false);
|
|
71
|
+
await daemonRelay("POST", { enabled: false }).catch(() => null);
|
|
72
|
+
console.log(`${chalk.green("✔")} remote access off ${chalk.dim("(paired devices keep their pairing; `kraftwerk devices revoke` unpairs one)")}`);
|
|
73
|
+
});
|
|
74
|
+
remote
|
|
75
|
+
.command("pair")
|
|
76
|
+
.description(`A link that pairs a device from anywhere, valid ${PAIR_CODE_TTL_MS / 60_000} minutes, once`)
|
|
77
|
+
.action(async () => {
|
|
78
|
+
const s = await readRelaySettings();
|
|
79
|
+
if (!s?.enabled) {
|
|
80
|
+
console.error(chalk.red("✖ remote access is off — `kraftwerk remote on` first"));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const { code } = await createPairCode();
|
|
84
|
+
console.log(`${chalk.green("✔")} open this on the device ${chalk.dim(`— valid ${PAIR_CODE_TTL_MS / 60_000} minutes, once`)}`);
|
|
85
|
+
console.log(` ${chalk.cyan(remoteLink(s.url, s.publicKey, code))}`);
|
|
86
|
+
console.log(chalk.dim(` The link carries this machine's key and the code ${code}; treat it like a password.`));
|
|
87
|
+
console.log(chalk.dim(" Or scan its QR code under Settings → Devices in the web UI."));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay: reaching a machine's kraftwerk from anywhere, end to end
|
|
3
|
+
* encrypted. The daemon holds an outgoing socket to a relay (kraftwerk
|
|
4
|
+
* cloud); a client — a phone's browser, an app — opens a socket to the same
|
|
5
|
+
* relay naming the machine, and the relay pipes the two together. The relay
|
|
6
|
+
* sees who talks to which machine, when, and how much; never what.
|
|
7
|
+
*
|
|
8
|
+
* This module is both ends of the encryption and the client's half of the
|
|
9
|
+
* tunnel. It runs wherever WebCrypto does (Node 20+, browsers, service
|
|
10
|
+
* workers), so the daemon, the CLI and the phone share one implementation.
|
|
11
|
+
*
|
|
12
|
+
* Keys. The daemon has a long-lived X25519 key; its public half is the
|
|
13
|
+
* pairing link's trust anchor (`#k=…`), and the relay knows the machine by
|
|
14
|
+
* its hash (`relayId`). Each connection:
|
|
15
|
+
*
|
|
16
|
+
* client → daemon {t:"hello", v:1, key: e_c} plaintext
|
|
17
|
+
* daemon → client {t:"ready", key: e_d, box: seal("ready")} plaintext + sealed proof
|
|
18
|
+
*
|
|
19
|
+
* Both derive two AES-256-GCM keys (one per direction) with HKDF-SHA256 from
|
|
20
|
+
* DH(e_c, S_d) ‖ DH(e_c, e_d), salted with e_c ‖ S_d ‖ e_d. Only the holder
|
|
21
|
+
* of S_d can open the client's traffic or produce "ready" (the client knows
|
|
22
|
+
* it reached the right machine); e_d makes every connection's keys fresh, so
|
|
23
|
+
* a recorded session cannot be replayed to the daemon. Nonces are message
|
|
24
|
+
* counters per direction, so a dropped, repeated or reordered message
|
|
25
|
+
* breaks the channel instead of slipping through.
|
|
26
|
+
*
|
|
27
|
+
* The client is not authenticated by the handshake: inside the channel it
|
|
28
|
+
* presents a paired device's token like any network caller, and the daemon
|
|
29
|
+
* applies the same trust rules (src/server/trust.ts). Anyone holding the
|
|
30
|
+
* pairing link still needs a pairing code or a token.
|
|
31
|
+
*/
|
|
32
|
+
export declare const RELAY_PROTOCOL = 1;
|
|
33
|
+
export declare function toBase64(bytes: Uint8Array): string;
|
|
34
|
+
export declare function fromBase64(text: string): Uint8Array;
|
|
35
|
+
export declare const toBase64Url: (bytes: Uint8Array) => string;
|
|
36
|
+
export declare const fromBase64Url: (text: string) => Uint8Array;
|
|
37
|
+
export interface KeyPair {
|
|
38
|
+
privateKey: CryptoKey;
|
|
39
|
+
/** The raw 32-byte public key. */
|
|
40
|
+
publicKey: Uint8Array;
|
|
41
|
+
}
|
|
42
|
+
export declare function generateKeyPair(): Promise<KeyPair>;
|
|
43
|
+
/** A key pair as JSON (the private half as a JWK) — what the daemon keeps on disk. */
|
|
44
|
+
export declare function exportKeyPair(pair: KeyPair): Promise<{
|
|
45
|
+
publicKey: string;
|
|
46
|
+
privateKey: JsonWebKey;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function importKeyPair(saved: {
|
|
49
|
+
publicKey: string;
|
|
50
|
+
privateKey: JsonWebKey;
|
|
51
|
+
}): Promise<KeyPair>;
|
|
52
|
+
/** How the relay knows a machine: the hash of its public key, so the pairing link alone finds it. */
|
|
53
|
+
export declare function relayId(publicKey: Uint8Array): Promise<string>;
|
|
54
|
+
/** One connection's two directions: seal what goes out, open what comes in, each in strict order. */
|
|
55
|
+
export interface Channel {
|
|
56
|
+
seal(plaintext: string): Promise<string>;
|
|
57
|
+
open(sealed: string): Promise<string>;
|
|
58
|
+
}
|
|
59
|
+
export interface Hello {
|
|
60
|
+
t: "hello";
|
|
61
|
+
v: number;
|
|
62
|
+
key: string;
|
|
63
|
+
}
|
|
64
|
+
export interface Ready {
|
|
65
|
+
t: "ready";
|
|
66
|
+
key: string;
|
|
67
|
+
box: string;
|
|
68
|
+
}
|
|
69
|
+
/** The client's side: a hello to send, and `finish` to check the daemon's ready and get the channel. */
|
|
70
|
+
export declare function clientHandshake(daemonPublicKey: Uint8Array): Promise<{
|
|
71
|
+
hello: Hello;
|
|
72
|
+
finish(ready: Ready): Promise<Channel>;
|
|
73
|
+
}>;
|
|
74
|
+
/** The daemon's side: answer a client's hello with a ready, and get the channel. */
|
|
75
|
+
export declare function daemonHandshake(own: KeyPair, hello: Hello): Promise<{
|
|
76
|
+
ready: Ready;
|
|
77
|
+
channel: Channel;
|
|
78
|
+
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Inside the channel, the client sends requests and the daemon answers them
|
|
81
|
+
* from its own server, streaming the body (event streams stay open):
|
|
82
|
+
*
|
|
83
|
+
* → {t:"req", id, method, url, headers, body?} body base64
|
|
84
|
+
* ← {t:"res", id, status, headers}
|
|
85
|
+
* ← {t:"data", id, chunk} base64, any number
|
|
86
|
+
* ← {t:"end", id} | {t:"fail", id, error}
|
|
87
|
+
* → {t:"abort", id} the client stopped reading
|
|
88
|
+
*/
|
|
89
|
+
export type TunnelToDaemon = {
|
|
90
|
+
t: "req";
|
|
91
|
+
id: number;
|
|
92
|
+
method: string;
|
|
93
|
+
url: string;
|
|
94
|
+
headers: Record<string, string>;
|
|
95
|
+
body?: string;
|
|
96
|
+
} | {
|
|
97
|
+
t: "abort";
|
|
98
|
+
id: number;
|
|
99
|
+
};
|
|
100
|
+
export type TunnelToClient = {
|
|
101
|
+
t: "res";
|
|
102
|
+
id: number;
|
|
103
|
+
status: number;
|
|
104
|
+
headers: Record<string, string>;
|
|
105
|
+
} | {
|
|
106
|
+
t: "data";
|
|
107
|
+
id: number;
|
|
108
|
+
chunk: string;
|
|
109
|
+
} | {
|
|
110
|
+
t: "end";
|
|
111
|
+
id: number;
|
|
112
|
+
} | {
|
|
113
|
+
t: "fail";
|
|
114
|
+
id: number;
|
|
115
|
+
error: string;
|
|
116
|
+
};
|
|
117
|
+
/** The socket the client talks over — the browser's WebSocket and Node's global one both fit. */
|
|
118
|
+
export interface RelaySocket {
|
|
119
|
+
send(data: string): void;
|
|
120
|
+
close(code?: number, reason?: string): void;
|
|
121
|
+
addEventListener(type: "message", fn: (ev: {
|
|
122
|
+
data: unknown;
|
|
123
|
+
}) => void): void;
|
|
124
|
+
addEventListener(type: "close", fn: (ev: {
|
|
125
|
+
code?: number;
|
|
126
|
+
reason?: string;
|
|
127
|
+
}) => void): void;
|
|
128
|
+
addEventListener(type: "open" | "error", fn: () => void): void;
|
|
129
|
+
readonly readyState: number;
|
|
130
|
+
}
|
|
131
|
+
export interface Tunnel {
|
|
132
|
+
/** `fetch` through the machine: paths are the daemon's (`/w/<slug>/api/…`); hand it to createClient({fetch}). */
|
|
133
|
+
fetch: typeof fetch;
|
|
134
|
+
close(): void;
|
|
135
|
+
/** Resolves when the connection ends, with why. */
|
|
136
|
+
readonly closed: Promise<string>;
|
|
137
|
+
}
|
|
138
|
+
export interface ConnectOptions {
|
|
139
|
+
/** The relay's client endpoint, e.g. wss://srv.kraftwerk-cloud.netnode.cloud/api/relay/client */
|
|
140
|
+
relayUrl: string;
|
|
141
|
+
/** The machine's public key, from its pairing link. */
|
|
142
|
+
publicKey: Uint8Array;
|
|
143
|
+
/** Opens the socket (default: the global WebSocket). */
|
|
144
|
+
connect?: (url: string) => RelaySocket;
|
|
145
|
+
/** Abort the handshake after this long (default 15 s). */
|
|
146
|
+
timeoutMs?: number;
|
|
147
|
+
}
|
|
148
|
+
/** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
|
|
149
|
+
export declare function connectTunnel(opts: ConnectOptions): Promise<Tunnel>;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay: reaching a machine's kraftwerk from anywhere, end to end
|
|
3
|
+
* encrypted. The daemon holds an outgoing socket to a relay (kraftwerk
|
|
4
|
+
* cloud); a client — a phone's browser, an app — opens a socket to the same
|
|
5
|
+
* relay naming the machine, and the relay pipes the two together. The relay
|
|
6
|
+
* sees who talks to which machine, when, and how much; never what.
|
|
7
|
+
*
|
|
8
|
+
* This module is both ends of the encryption and the client's half of the
|
|
9
|
+
* tunnel. It runs wherever WebCrypto does (Node 20+, browsers, service
|
|
10
|
+
* workers), so the daemon, the CLI and the phone share one implementation.
|
|
11
|
+
*
|
|
12
|
+
* Keys. The daemon has a long-lived X25519 key; its public half is the
|
|
13
|
+
* pairing link's trust anchor (`#k=…`), and the relay knows the machine by
|
|
14
|
+
* its hash (`relayId`). Each connection:
|
|
15
|
+
*
|
|
16
|
+
* client → daemon {t:"hello", v:1, key: e_c} plaintext
|
|
17
|
+
* daemon → client {t:"ready", key: e_d, box: seal("ready")} plaintext + sealed proof
|
|
18
|
+
*
|
|
19
|
+
* Both derive two AES-256-GCM keys (one per direction) with HKDF-SHA256 from
|
|
20
|
+
* DH(e_c, S_d) ‖ DH(e_c, e_d), salted with e_c ‖ S_d ‖ e_d. Only the holder
|
|
21
|
+
* of S_d can open the client's traffic or produce "ready" (the client knows
|
|
22
|
+
* it reached the right machine); e_d makes every connection's keys fresh, so
|
|
23
|
+
* a recorded session cannot be replayed to the daemon. Nonces are message
|
|
24
|
+
* counters per direction, so a dropped, repeated or reordered message
|
|
25
|
+
* breaks the channel instead of slipping through.
|
|
26
|
+
*
|
|
27
|
+
* The client is not authenticated by the handshake: inside the channel it
|
|
28
|
+
* presents a paired device's token like any network caller, and the daemon
|
|
29
|
+
* applies the same trust rules (src/server/trust.ts). Anyone holding the
|
|
30
|
+
* pairing link still needs a pairing code or a token.
|
|
31
|
+
*/
|
|
32
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
const dec = new TextDecoder();
|
|
35
|
+
export const RELAY_PROTOCOL = 1;
|
|
36
|
+
/* ---------- encoding ---------- */
|
|
37
|
+
export function toBase64(bytes) {
|
|
38
|
+
let s = "";
|
|
39
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
40
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
41
|
+
return btoa(s);
|
|
42
|
+
}
|
|
43
|
+
export function fromBase64(text) {
|
|
44
|
+
const s = atob(text);
|
|
45
|
+
const out = new Uint8Array(s.length);
|
|
46
|
+
for (let i = 0; i < s.length; i++)
|
|
47
|
+
out[i] = s.charCodeAt(i);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export const toBase64Url = (bytes) => toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
|
+
export const fromBase64Url = (text) => fromBase64(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
52
|
+
const concat = (...parts) => {
|
|
53
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
54
|
+
let o = 0;
|
|
55
|
+
for (const p of parts) {
|
|
56
|
+
out.set(p, o);
|
|
57
|
+
o += p.length;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
export async function generateKeyPair() {
|
|
62
|
+
const pair = (await subtle().generateKey({ name: "X25519" }, true, ["deriveBits"]));
|
|
63
|
+
return { privateKey: pair.privateKey, publicKey: new Uint8Array(await subtle().exportKey("raw", pair.publicKey)) };
|
|
64
|
+
}
|
|
65
|
+
/** A key pair as JSON (the private half as a JWK) — what the daemon keeps on disk. */
|
|
66
|
+
export async function exportKeyPair(pair) {
|
|
67
|
+
return { publicKey: toBase64Url(pair.publicKey), privateKey: await subtle().exportKey("jwk", pair.privateKey) };
|
|
68
|
+
}
|
|
69
|
+
export async function importKeyPair(saved) {
|
|
70
|
+
const privateKey = await subtle().importKey("jwk", saved.privateKey, { name: "X25519" }, true, ["deriveBits"]);
|
|
71
|
+
return { privateKey, publicKey: fromBase64Url(saved.publicKey) };
|
|
72
|
+
}
|
|
73
|
+
const importPublic = (raw) => {
|
|
74
|
+
if (raw.length !== 32)
|
|
75
|
+
throw new Error("invalid public key");
|
|
76
|
+
return subtle().importKey("raw", raw, { name: "X25519" }, false, []);
|
|
77
|
+
};
|
|
78
|
+
/** X25519; WebCrypto refuses a low-order peer key (an all-zero secret). */
|
|
79
|
+
const dh = async (own, peer) => new Uint8Array(await subtle().deriveBits({ name: "X25519", public: await importPublic(peer) }, own, 256));
|
|
80
|
+
/** How the relay knows a machine: the hash of its public key, so the pairing link alone finds it. */
|
|
81
|
+
export async function relayId(publicKey) {
|
|
82
|
+
const digest = new Uint8Array(await subtle().digest("SHA-256", publicKey));
|
|
83
|
+
return toBase64Url(digest.subarray(0, 16));
|
|
84
|
+
}
|
|
85
|
+
async function channelKeys(ikm, salt) {
|
|
86
|
+
const base = await subtle().importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
|
|
87
|
+
const derive = (info) => subtle().deriveKey({ name: "HKDF", hash: "SHA-256", salt: salt, info: enc.encode(`kraftwerk-relay-v1 ${info}`) }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
88
|
+
return { toDaemon: await derive("to-daemon"), toClient: await derive("to-client") };
|
|
89
|
+
}
|
|
90
|
+
const nonce = (n) => {
|
|
91
|
+
const iv = new Uint8Array(12);
|
|
92
|
+
new DataView(iv.buffer).setBigUint64(4, BigInt(n));
|
|
93
|
+
return iv;
|
|
94
|
+
};
|
|
95
|
+
function makeChannel(out, inn) {
|
|
96
|
+
let sent = 0;
|
|
97
|
+
let received = 0;
|
|
98
|
+
// Sealing is async: queue it so messages keep their counter order however callers interleave.
|
|
99
|
+
let sealing = Promise.resolve();
|
|
100
|
+
let opening = Promise.resolve();
|
|
101
|
+
return {
|
|
102
|
+
seal(plaintext) {
|
|
103
|
+
const run = sealing.then(async () => {
|
|
104
|
+
const iv = nonce(sent++);
|
|
105
|
+
return toBase64(new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: iv }, out, enc.encode(plaintext))));
|
|
106
|
+
});
|
|
107
|
+
sealing = run.catch(() => { });
|
|
108
|
+
return run;
|
|
109
|
+
},
|
|
110
|
+
open(sealed) {
|
|
111
|
+
const run = opening.then(async () => {
|
|
112
|
+
const iv = nonce(received++);
|
|
113
|
+
try {
|
|
114
|
+
return dec.decode(await subtle().decrypt({ name: "AES-GCM", iv: iv }, inn, fromBase64(sealed)));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error("relay message failed to decrypt — tampered, replayed or out of order");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
opening = run.catch(() => { });
|
|
121
|
+
return run;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The client's side: a hello to send, and `finish` to check the daemon's ready and get the channel. */
|
|
126
|
+
export async function clientHandshake(daemonPublicKey) {
|
|
127
|
+
const eph = await generateKeyPair();
|
|
128
|
+
return {
|
|
129
|
+
hello: { t: "hello", v: RELAY_PROTOCOL, key: toBase64(eph.publicKey) },
|
|
130
|
+
async finish(ready) {
|
|
131
|
+
if (ready?.t !== "ready" || typeof ready.key !== "string" || typeof ready.box !== "string")
|
|
132
|
+
throw new Error("not a relay handshake answer");
|
|
133
|
+
const daemonEph = fromBase64(ready.key);
|
|
134
|
+
const ikm = concat(await dh(eph.privateKey, daemonPublicKey), await dh(eph.privateKey, daemonEph));
|
|
135
|
+
const keys = await channelKeys(ikm, concat(eph.publicKey, daemonPublicKey, daemonEph));
|
|
136
|
+
const channel = makeChannel(keys.toDaemon, keys.toClient);
|
|
137
|
+
if ((await channel.open(ready.box).catch(() => "")) !== "ready")
|
|
138
|
+
throw new Error("the machine did not prove its key — wrong pairing link, or not the machine you paired with");
|
|
139
|
+
return channel;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** The daemon's side: answer a client's hello with a ready, and get the channel. */
|
|
144
|
+
export async function daemonHandshake(own, hello) {
|
|
145
|
+
if (hello?.t !== "hello" || typeof hello.key !== "string")
|
|
146
|
+
throw new Error("not a relay hello");
|
|
147
|
+
if (hello.v !== RELAY_PROTOCOL)
|
|
148
|
+
throw new Error(`relay protocol ${hello.v} is not supported (this kraftwerk speaks ${RELAY_PROTOCOL})`);
|
|
149
|
+
const clientEph = fromBase64(hello.key);
|
|
150
|
+
const eph = await generateKeyPair();
|
|
151
|
+
const ikm = concat(await dh(own.privateKey, clientEph), await dh(eph.privateKey, clientEph));
|
|
152
|
+
const keys = await channelKeys(ikm, concat(clientEph, own.publicKey, eph.publicKey));
|
|
153
|
+
const channel = makeChannel(keys.toClient, keys.toDaemon);
|
|
154
|
+
return { ready: { t: "ready", key: toBase64(eph.publicKey), box: await channel.seal("ready") }, channel };
|
|
155
|
+
}
|
|
156
|
+
const OPEN = 1;
|
|
157
|
+
/** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
|
|
158
|
+
export async function connectTunnel(opts) {
|
|
159
|
+
const id = await relayId(opts.publicKey);
|
|
160
|
+
const url = `${opts.relayUrl}${opts.relayUrl.includes("?") ? "&" : "?"}id=${encodeURIComponent(id)}`;
|
|
161
|
+
// The keys first: once the socket exists, its events may fire at any await — the listeners below must already be there.
|
|
162
|
+
const hs = await clientHandshake(opts.publicKey);
|
|
163
|
+
const ws = opts.connect ? opts.connect(url) : new WebSocket(url);
|
|
164
|
+
let channel = null;
|
|
165
|
+
let closedWhy = "";
|
|
166
|
+
let resolveClosed;
|
|
167
|
+
const closed = new Promise((r) => (resolveClosed = r));
|
|
168
|
+
const pending = new Map();
|
|
169
|
+
let nextId = 1;
|
|
170
|
+
const finish = (why) => {
|
|
171
|
+
if (closedWhy)
|
|
172
|
+
return;
|
|
173
|
+
closedWhy = why || "the connection closed";
|
|
174
|
+
for (const p of pending.values()) {
|
|
175
|
+
p.reject(new Error(closedWhy));
|
|
176
|
+
try {
|
|
177
|
+
p.stream?.error(new Error(closedWhy));
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
}
|
|
181
|
+
pending.clear();
|
|
182
|
+
resolveClosed(closedWhy);
|
|
183
|
+
};
|
|
184
|
+
const ready = new Promise((resolve, reject) => {
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
reject(new Error("the machine did not answer through the relay"));
|
|
187
|
+
ws.close();
|
|
188
|
+
}, opts.timeoutMs ?? 15_000);
|
|
189
|
+
ws.addEventListener("open", () => ws.send(JSON.stringify(hs.hello)));
|
|
190
|
+
ws.addEventListener("close", (ev) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
const why = ev.reason || (ev.code === 4404 ? "the machine is not connected to the relay" : "the relay closed the connection");
|
|
193
|
+
reject(new Error(why));
|
|
194
|
+
finish(why);
|
|
195
|
+
});
|
|
196
|
+
ws.addEventListener("error", () => { });
|
|
197
|
+
let first = true;
|
|
198
|
+
// Messages are opened one after another (the channel counts them); a promise chain keeps the order.
|
|
199
|
+
let queue = Promise.resolve();
|
|
200
|
+
ws.addEventListener("message", (ev) => {
|
|
201
|
+
const text = typeof ev.data === "string" ? ev.data : dec.decode(ev.data);
|
|
202
|
+
queue = queue.then(async () => {
|
|
203
|
+
if (first) {
|
|
204
|
+
first = false;
|
|
205
|
+
try {
|
|
206
|
+
channel = await hs.finish(JSON.parse(text));
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
resolve(channel);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
reject(err);
|
|
213
|
+
ws.close(4400, "handshake failed");
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!channel)
|
|
218
|
+
return;
|
|
219
|
+
let msg;
|
|
220
|
+
try {
|
|
221
|
+
msg = JSON.parse(await channel.open(text));
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
ws.close(4400, "bad message");
|
|
225
|
+
finish(err.message);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
receive(msg);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
const receive = (msg) => {
|
|
233
|
+
const p = pending.get(msg.id);
|
|
234
|
+
if (!p)
|
|
235
|
+
return;
|
|
236
|
+
if (msg.t === "res") {
|
|
237
|
+
const body = new ReadableStream({
|
|
238
|
+
start: (c) => {
|
|
239
|
+
p.stream = c;
|
|
240
|
+
},
|
|
241
|
+
cancel: () => {
|
|
242
|
+
pending.delete(msg.id);
|
|
243
|
+
void send({ t: "abort", id: msg.id });
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const status = msg.status;
|
|
247
|
+
// A Response cannot carry a body for these; the stream stays unused.
|
|
248
|
+
const noBody = status === 204 || status === 304 || (status >= 100 && status < 200);
|
|
249
|
+
p.resolve(new Response(noBody ? null : body, { status, headers: msg.headers }));
|
|
250
|
+
}
|
|
251
|
+
else if (msg.t === "data") {
|
|
252
|
+
try {
|
|
253
|
+
p.stream?.enqueue(fromBase64(msg.chunk));
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
}
|
|
257
|
+
else if (msg.t === "end") {
|
|
258
|
+
pending.delete(msg.id);
|
|
259
|
+
try {
|
|
260
|
+
p.stream?.close();
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
}
|
|
264
|
+
else if (msg.t === "fail") {
|
|
265
|
+
pending.delete(msg.id);
|
|
266
|
+
const err = new Error(msg.error);
|
|
267
|
+
p.reject(err);
|
|
268
|
+
try {
|
|
269
|
+
p.stream?.error(err);
|
|
270
|
+
}
|
|
271
|
+
catch { }
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const send = async (msg) => {
|
|
275
|
+
if (!channel || ws.readyState !== OPEN)
|
|
276
|
+
return;
|
|
277
|
+
ws.send(await channel.seal(JSON.stringify(msg)));
|
|
278
|
+
};
|
|
279
|
+
await ready;
|
|
280
|
+
const tunnelFetch = async (input, init) => {
|
|
281
|
+
if (closedWhy)
|
|
282
|
+
throw new Error(closedWhy);
|
|
283
|
+
const req = new Request(typeof input === "string" && input.startsWith("/") ? `http://relay${input}` : input, init);
|
|
284
|
+
const u = new URL(req.url);
|
|
285
|
+
const headers = {};
|
|
286
|
+
req.headers.forEach((v, k) => (headers[k] = v));
|
|
287
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : new Uint8Array(await req.arrayBuffer());
|
|
288
|
+
const id = nextId++;
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
pending.set(id, { resolve, reject, signal: req.signal });
|
|
291
|
+
req.signal?.addEventListener("abort", () => {
|
|
292
|
+
const p = pending.get(id);
|
|
293
|
+
if (!p)
|
|
294
|
+
return;
|
|
295
|
+
pending.delete(id);
|
|
296
|
+
const err = new DOMException("aborted", "AbortError");
|
|
297
|
+
p.reject(err);
|
|
298
|
+
try {
|
|
299
|
+
p.stream?.error(err);
|
|
300
|
+
}
|
|
301
|
+
catch { }
|
|
302
|
+
void send({ t: "abort", id });
|
|
303
|
+
});
|
|
304
|
+
send({ t: "req", id, method: req.method, url: u.pathname + u.search, headers, ...(body?.length ? { body: toBase64(body) } : {}) }).catch((err) => {
|
|
305
|
+
pending.delete(id);
|
|
306
|
+
reject(err);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
return {
|
|
311
|
+
fetch: tunnelFetch,
|
|
312
|
+
close: () => {
|
|
313
|
+
ws.close(1000, "bye");
|
|
314
|
+
finish("closed");
|
|
315
|
+
},
|
|
316
|
+
closed,
|
|
317
|
+
};
|
|
318
|
+
}
|