@wtfalch/foundry 0.4.0 → 0.6.0
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/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure decision logic for
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* `
|
|
2
|
+
* Pure decision logic for the calls SFU's SSH/SFTP deploy, split out so it is
|
|
3
|
+
* unit-tested by vitest against `src/**` -- before `pnpm build` produces
|
|
4
|
+
* `dist` -- the same way `calls-sfu.ts` is, rather than only by hand against
|
|
5
|
+
* a real VM. `calls-sfu-ssh-adapter.ts` applies every export here to a
|
|
6
|
+
* {@link CallsSfuSshSession} (`toCallsSfuHostPort`/`applyCallsSfuOverSession`)
|
|
7
|
+
* -- the one place both `scripts/calls-sfu-deploy.mjs` (#122) and the
|
|
8
|
+
* platform `sfu` step (`plan/platform-sfu.ts`, #170) use it. The real SSH
|
|
9
|
+
* session -- `ssh2`'s `Client`/`sftp`/`exec` wiring -- stays untested, same
|
|
10
|
+
* as `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
|
|
8
11
|
* logic worth a fake session over.
|
|
9
12
|
*
|
|
10
13
|
* Each export here answers one finding from PR #125's review (#122):
|
|
@@ -35,6 +38,14 @@ export declare function verifyHostKey(rawKey: Buffer, expectedFingerprint: strin
|
|
|
35
38
|
* this script writes holds no secret. Returns the SFTP file mode to write
|
|
36
39
|
* with, or `undefined` for anything else (the SFTP server's own default).
|
|
37
40
|
* PR #125's review, finding 2; extended for #127's second secret file.
|
|
41
|
+
*
|
|
42
|
+
* A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
|
|
43
|
+
* `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
|
|
44
|
+
* it into place (#152 round 2 review, finding 3 -- a temp file plus an
|
|
45
|
+
* atomic rename, rather than overwriting the live config two concurrent
|
|
46
|
+
* applies could otherwise tear). The temp file holds the same secret the
|
|
47
|
+
* final path does, so it gets the same 0600 mode; `mv` preserves it across
|
|
48
|
+
* the rename.
|
|
38
49
|
*/
|
|
39
50
|
export declare function fileWriteMode(path: string): number | undefined;
|
|
40
51
|
/**
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure decision logic for
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* `
|
|
2
|
+
* Pure decision logic for the calls SFU's SSH/SFTP deploy, split out so it is
|
|
3
|
+
* unit-tested by vitest against `src/**` -- before `pnpm build` produces
|
|
4
|
+
* `dist` -- the same way `calls-sfu.ts` is, rather than only by hand against
|
|
5
|
+
* a real VM. `calls-sfu-ssh-adapter.ts` applies every export here to a
|
|
6
|
+
* {@link CallsSfuSshSession} (`toCallsSfuHostPort`/`applyCallsSfuOverSession`)
|
|
7
|
+
* -- the one place both `scripts/calls-sfu-deploy.mjs` (#122) and the
|
|
8
|
+
* platform `sfu` step (`plan/platform-sfu.ts`, #170) use it. The real SSH
|
|
9
|
+
* session -- `ssh2`'s `Client`/`sftp`/`exec` wiring -- stays untested, same
|
|
10
|
+
* as `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
|
|
8
11
|
* logic worth a fake session over.
|
|
9
12
|
*
|
|
10
13
|
* Each export here answers one finding from PR #125's review (#122):
|
|
@@ -50,9 +53,18 @@ export function verifyHostKey(rawKey, expectedFingerprint) {
|
|
|
50
53
|
* this script writes holds no secret. Returns the SFTP file mode to write
|
|
51
54
|
* with, or `undefined` for anything else (the SFTP server's own default).
|
|
52
55
|
* PR #125's review, finding 2; extended for #127's second secret file.
|
|
56
|
+
*
|
|
57
|
+
* A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
|
|
58
|
+
* `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
|
|
59
|
+
* it into place (#152 round 2 review, finding 3 -- a temp file plus an
|
|
60
|
+
* atomic rename, rather than overwriting the live config two concurrent
|
|
61
|
+
* applies could otherwise tear). The temp file holds the same secret the
|
|
62
|
+
* final path does, so it gets the same 0600 mode; `mv` preserves it across
|
|
63
|
+
* the rename.
|
|
53
64
|
*/
|
|
54
65
|
export function fileWriteMode(path) {
|
|
55
|
-
|
|
66
|
+
const target = path.endsWith('.tmp') ? path.slice(0, -'.tmp'.length) : path;
|
|
67
|
+
return target === LIVEKIT_CONFIG_PATH || target === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
|
|
56
68
|
}
|
|
57
69
|
// ---------------------------------------------------------------------------
|
|
58
70
|
// 3. Preflight
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mints the LiveKit API key/secret pair the shared calls SFU signs tokens
|
|
3
|
+
* with (#170). Before this, `scripts/calls-sfu-deploy.mjs` read
|
|
4
|
+
* `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` from the environment -- a human
|
|
5
|
+
* minted the pair by hand and put it in the secrets store. The platform
|
|
6
|
+
* `sfu` step (`plan/platform-sfu.ts`) mints it instead, once, and delivers it
|
|
7
|
+
* to held keys through `CallsSfuKeysPort.deliverLivekitKeys`; every apply
|
|
8
|
+
* after that reads the same pair back through `resolveLivekitKeys` rather
|
|
9
|
+
* than minting a second one.
|
|
10
|
+
*
|
|
11
|
+
* The shape matches LiveKit's own self-hosting docs: an `API`-prefixed key
|
|
12
|
+
* id and a long random secret, both plain alphanumeric so they drop straight
|
|
13
|
+
* into `livekit.yaml`'s `keys:` map (`calls-sfu.ts`'s `livekitConfigYaml`)
|
|
14
|
+
* with no escaping to worry about.
|
|
15
|
+
*/
|
|
16
|
+
export interface LivekitCredentials {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
apiSecret: string;
|
|
19
|
+
}
|
|
20
|
+
/** A fresh LiveKit API key/secret pair, minted with `node:crypto`'s CSPRNG. */
|
|
21
|
+
export declare function mintLivekitCredentials(): LivekitCredentials;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mints the LiveKit API key/secret pair the shared calls SFU signs tokens
|
|
3
|
+
* with (#170). Before this, `scripts/calls-sfu-deploy.mjs` read
|
|
4
|
+
* `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` from the environment -- a human
|
|
5
|
+
* minted the pair by hand and put it in the secrets store. The platform
|
|
6
|
+
* `sfu` step (`plan/platform-sfu.ts`) mints it instead, once, and delivers it
|
|
7
|
+
* to held keys through `CallsSfuKeysPort.deliverLivekitKeys`; every apply
|
|
8
|
+
* after that reads the same pair back through `resolveLivekitKeys` rather
|
|
9
|
+
* than minting a second one.
|
|
10
|
+
*
|
|
11
|
+
* The shape matches LiveKit's own self-hosting docs: an `API`-prefixed key
|
|
12
|
+
* id and a long random secret, both plain alphanumeric so they drop straight
|
|
13
|
+
* into `livekit.yaml`'s `keys:` map (`calls-sfu.ts`'s `livekitConfigYaml`)
|
|
14
|
+
* with no escaping to worry about.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
const ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
18
|
+
function randomAlphanumeric(length) {
|
|
19
|
+
return Array.from(randomBytes(length), (byte) => ALPHANUMERIC[byte % ALPHANUMERIC.length]).join('');
|
|
20
|
+
}
|
|
21
|
+
/** A fresh LiveKit API key/secret pair, minted with `node:crypto`'s CSPRNG. */
|
|
22
|
+
export function mintLivekitCredentials() {
|
|
23
|
+
return {
|
|
24
|
+
apiKey: `API${randomAlphanumeric(12)}`,
|
|
25
|
+
apiSecret: randomAlphanumeric(40),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns one open SSH session into the `CallsSfuHostPort` {@link applyCallsSfu}
|
|
3
|
+
* needs, applying `calls-sfu-deploy-support.ts`'s decision logic
|
|
4
|
+
* (`verifyHostKey`, `fileWriteMode`, the preflight check) exactly once so
|
|
5
|
+
* `scripts/calls-sfu-deploy.mjs` and the platform `sfu` step
|
|
6
|
+
* (`plan/platform-sfu.ts`, #170) share it rather than each re-deriving it.
|
|
7
|
+
*
|
|
8
|
+
* {@link CallsSfuSshSession} is deliberately narrow -- the host key an
|
|
9
|
+
* already-open session presented, a way to run a command, a way to write a
|
|
10
|
+
* file at a chosen mode -- so a test hands in a fake session and proves the
|
|
11
|
+
* host-key refusal and the 0600 file mode without ever opening a socket.
|
|
12
|
+
* `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh` is the one real
|
|
13
|
+
* implementation, over `ssh2`, and stays untested for the same reason
|
|
14
|
+
* `foundry-db-provision.mjs`/`foundry-db-migrate.mjs` do: real I/O, no
|
|
15
|
+
* branch logic worth a fake session over.
|
|
16
|
+
*/
|
|
17
|
+
import { type CallsSfuConfig, type CallsSfuHostPort } from './calls-sfu.js';
|
|
18
|
+
export declare class UnknownCallsSfuHostKeyError extends Error {
|
|
19
|
+
constructor();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One already-open SSH session, narrow enough to fake in a test: the raw
|
|
23
|
+
* host key it presented during the handshake, a way to run one command, and
|
|
24
|
+
* a way to write a file at a chosen mode (`undefined` for the SFTP server's
|
|
25
|
+
* own default, same as {@link CallsSfuHostPort.writeFile} today).
|
|
26
|
+
*/
|
|
27
|
+
export interface CallsSfuSshSession {
|
|
28
|
+
hostKey: Buffer;
|
|
29
|
+
exec(command: string): Promise<{
|
|
30
|
+
exitCode: number;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
}>;
|
|
34
|
+
writeFile(path: string, contents: string, mode: number | undefined): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Refuses with {@link UnknownCallsSfuHostKeyError} before adapting anything
|
|
38
|
+
* when `session.hostKey` does not match `expectedFingerprint` -- checked
|
|
39
|
+
* here, in code a fake session exercises directly, rather than only inside
|
|
40
|
+
* `ssh2`'s own `hostVerifier` (PR #125's review, finding 1). Otherwise
|
|
41
|
+
* returns a {@link CallsSfuHostPort} that writes each file at
|
|
42
|
+
* {@link fileWriteMode}'s mode (finding 2) and runs commands as-is.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toCallsSfuHostPort(session: CallsSfuSshSession, expectedFingerprint: string | undefined): CallsSfuHostPort;
|
|
45
|
+
/**
|
|
46
|
+
* The whole deploy, over one already-open session: refuse an unknown host
|
|
47
|
+
* key first (before any command runs), check `docker`/`ufw` are installed
|
|
48
|
+
* (PR #125's review, finding 3), make sure `/opt/livekit` exists, then hand
|
|
49
|
+
* off to {@link applyCallsSfu} -- the same order
|
|
50
|
+
* `scripts/calls-sfu-deploy.mjs` ran by hand, now shared with the platform
|
|
51
|
+
* `sfu` step. Every step here is safe to re-run, the same "safe to re-run"
|
|
52
|
+
* {@link applyCallsSfu} itself promises, so calling this twice with the same
|
|
53
|
+
* `session`/`config` changes nothing on the second call.
|
|
54
|
+
*/
|
|
55
|
+
export declare function applyCallsSfuOverSession(session: CallsSfuSshSession, config: CallsSfuConfig, expectedFingerprint: string | undefined): Promise<void>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns one open SSH session into the `CallsSfuHostPort` {@link applyCallsSfu}
|
|
3
|
+
* needs, applying `calls-sfu-deploy-support.ts`'s decision logic
|
|
4
|
+
* (`verifyHostKey`, `fileWriteMode`, the preflight check) exactly once so
|
|
5
|
+
* `scripts/calls-sfu-deploy.mjs` and the platform `sfu` step
|
|
6
|
+
* (`plan/platform-sfu.ts`, #170) share it rather than each re-deriving it.
|
|
7
|
+
*
|
|
8
|
+
* {@link CallsSfuSshSession} is deliberately narrow -- the host key an
|
|
9
|
+
* already-open session presented, a way to run a command, a way to write a
|
|
10
|
+
* file at a chosen mode -- so a test hands in a fake session and proves the
|
|
11
|
+
* host-key refusal and the 0600 file mode without ever opening a socket.
|
|
12
|
+
* `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh` is the one real
|
|
13
|
+
* implementation, over `ssh2`, and stays untested for the same reason
|
|
14
|
+
* `foundry-db-provision.mjs`/`foundry-db-migrate.mjs` do: real I/O, no
|
|
15
|
+
* branch logic worth a fake session over.
|
|
16
|
+
*/
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { PREFLIGHT_COMMAND, fileWriteMode, preflightFailureMessage, verifyHostKey, } from './calls-sfu-deploy-support.js';
|
|
19
|
+
import { LIVEKIT_COMPOSE_PATH, LIVEKIT_CONFIG_PATH, applyCallsSfu, livekitConfigYaml, } from './calls-sfu.js';
|
|
20
|
+
export class UnknownCallsSfuHostKeyError extends Error {
|
|
21
|
+
constructor() {
|
|
22
|
+
super('The calls SFU host key did not match the fingerprint held for it; refusing to ' +
|
|
23
|
+
'deploy. See calls-sfu-deploy-support.ts’s verifyHostKey (PR #125) -- the ' +
|
|
24
|
+
'expected fingerprint comes from held keys (#170’s "Human steps").');
|
|
25
|
+
this.name = 'UnknownCallsSfuHostKeyError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Refuses with {@link UnknownCallsSfuHostKeyError} before adapting anything
|
|
30
|
+
* when `session.hostKey` does not match `expectedFingerprint` -- checked
|
|
31
|
+
* here, in code a fake session exercises directly, rather than only inside
|
|
32
|
+
* `ssh2`'s own `hostVerifier` (PR #125's review, finding 1). Otherwise
|
|
33
|
+
* returns a {@link CallsSfuHostPort} that writes each file at
|
|
34
|
+
* {@link fileWriteMode}'s mode (finding 2) and runs commands as-is.
|
|
35
|
+
*/
|
|
36
|
+
export function toCallsSfuHostPort(session, expectedFingerprint) {
|
|
37
|
+
if (!verifyHostKey(session.hostKey, expectedFingerprint)) {
|
|
38
|
+
throw new UnknownCallsSfuHostKeyError();
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
writeFile: (path, contents) => session.writeFile(path, contents, fileWriteMode(path)),
|
|
42
|
+
exec: (command) => session.exec(command),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The whole deploy, over one already-open session: refuse an unknown host
|
|
47
|
+
* key first (before any command runs), check `docker`/`ufw` are installed
|
|
48
|
+
* (PR #125's review, finding 3), make sure `/opt/livekit` exists, then hand
|
|
49
|
+
* off to {@link applyCallsSfu} -- the same order
|
|
50
|
+
* `scripts/calls-sfu-deploy.mjs` ran by hand, now shared with the platform
|
|
51
|
+
* `sfu` step. Every step here is safe to re-run, the same "safe to re-run"
|
|
52
|
+
* {@link applyCallsSfu} itself promises, so calling this twice with the same
|
|
53
|
+
* `session`/`config` changes nothing on the second call.
|
|
54
|
+
*/
|
|
55
|
+
export async function applyCallsSfuOverSession(session, config, expectedFingerprint) {
|
|
56
|
+
const host = toCallsSfuHostPort(session, expectedFingerprint);
|
|
57
|
+
const preflight = await host.exec(PREFLIGHT_COMMAND);
|
|
58
|
+
if (preflight.exitCode !== 0) {
|
|
59
|
+
throw new Error(preflightFailureMessage(preflight));
|
|
60
|
+
}
|
|
61
|
+
const mkdir = await host.exec(`mkdir -p ${dirname(LIVEKIT_COMPOSE_PATH)}`);
|
|
62
|
+
if (mkdir.exitCode !== 0) {
|
|
63
|
+
throw new Error(`mkdir -p failed: ${mkdir.stderr || mkdir.stdout}`);
|
|
64
|
+
}
|
|
65
|
+
// `docker compose up -d` leaves a running container alone when the compose
|
|
66
|
+
// file is unchanged, so LiveKit would keep the old keys in memory after a
|
|
67
|
+
// new `livekit.yaml` lands. Read the config before it is overwritten and
|
|
68
|
+
// restart LiveKit afterwards if the keys (or anything else in it) changed.
|
|
69
|
+
const previous = await host.exec(`cat ${LIVEKIT_CONFIG_PATH}`);
|
|
70
|
+
const configChanged = previous.exitCode === 0 && previous.stdout !== livekitConfigYaml(config);
|
|
71
|
+
await applyCallsSfu(host, config);
|
|
72
|
+
if (configChanged) {
|
|
73
|
+
const restart = await host.exec(`docker compose -f ${LIVEKIT_COMPOSE_PATH} restart livekit`);
|
|
74
|
+
if (restart.exitCode !== 0) {
|
|
75
|
+
throw new Error(`restarting livekit failed: ${restart.stderr || restart.stdout}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real {@link CallsSfuSshConnector} (`plan/platform-sfu.ts`), over `ssh2`:
|
|
3
|
+
* the same `connect()`/`exec`/SFTP wiring `scripts/calls-sfu-deploy.mjs` uses
|
|
4
|
+
* by hand. A host (archon) passes {@link connectCallsSfuSsh} as the `ssh` dep
|
|
5
|
+
* of `CallsSfuPlatformDeps`.
|
|
6
|
+
*
|
|
7
|
+
* The host key is verified inside `ssh2`'s own `hostVerifier`, which returns
|
|
8
|
+
* `false` on a mismatch or a missing fingerprint so the handshake is refused
|
|
9
|
+
* before any authentication happens and the private key is never offered to
|
|
10
|
+
* an unknown host. `applyCallsSfuOverSession` checks the captured key again.
|
|
11
|
+
*
|
|
12
|
+
* `ssh2` is loaded lazily and the `Client` class can be injected, so a test
|
|
13
|
+
* hands in a fake and never opens a socket.
|
|
14
|
+
*/
|
|
15
|
+
import type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
|
|
16
|
+
interface SshStream {
|
|
17
|
+
on(event: string, listener: (...args: never[]) => void): SshStream;
|
|
18
|
+
stderr: {
|
|
19
|
+
on(event: string, listener: (...args: never[]) => void): unknown;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
interface SshSftp {
|
|
23
|
+
writeFile(path: string, contents: string, options: {
|
|
24
|
+
mode: number;
|
|
25
|
+
} | undefined, cb: (err?: Error | null) => void): void;
|
|
26
|
+
chmod(path: string, mode: number, cb: (err?: Error | null) => void): void;
|
|
27
|
+
}
|
|
28
|
+
interface SshClient {
|
|
29
|
+
on(event: string, listener: (...args: never[]) => void): SshClient;
|
|
30
|
+
connect(options: Record<string, unknown>): void;
|
|
31
|
+
exec(command: string, cb: (err: Error | undefined, stream: SshStream) => void): void;
|
|
32
|
+
sftp(cb: (err: Error | undefined, sftp: SshSftp) => void): void;
|
|
33
|
+
end(): void;
|
|
34
|
+
}
|
|
35
|
+
export type SshClientConstructor = new () => SshClient;
|
|
36
|
+
export interface CallsSfuSshConnectOptions {
|
|
37
|
+
address: string;
|
|
38
|
+
sshUser: string;
|
|
39
|
+
sshPort: number;
|
|
40
|
+
sshPrivateKey: string;
|
|
41
|
+
/** `SHA256:...`. Without it every host key is refused. */
|
|
42
|
+
sshHostFingerprint: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
export declare function connectCallsSfuSsh(options: CallsSfuSshConnectOptions, deps?: {
|
|
45
|
+
Client?: SshClientConstructor;
|
|
46
|
+
}): Promise<CallsSfuSshSession & {
|
|
47
|
+
close(): void;
|
|
48
|
+
}>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real {@link CallsSfuSshConnector} (`plan/platform-sfu.ts`), over `ssh2`:
|
|
3
|
+
* the same `connect()`/`exec`/SFTP wiring `scripts/calls-sfu-deploy.mjs` uses
|
|
4
|
+
* by hand. A host (archon) passes {@link connectCallsSfuSsh} as the `ssh` dep
|
|
5
|
+
* of `CallsSfuPlatformDeps`.
|
|
6
|
+
*
|
|
7
|
+
* The host key is verified inside `ssh2`'s own `hostVerifier`, which returns
|
|
8
|
+
* `false` on a mismatch or a missing fingerprint so the handshake is refused
|
|
9
|
+
* before any authentication happens and the private key is never offered to
|
|
10
|
+
* an unknown host. `applyCallsSfuOverSession` checks the captured key again.
|
|
11
|
+
*
|
|
12
|
+
* `ssh2` is loaded lazily and the `Client` class can be injected, so a test
|
|
13
|
+
* hands in a fake and never opens a socket.
|
|
14
|
+
*/
|
|
15
|
+
import { verifyHostKey } from './calls-sfu-deploy-support.js';
|
|
16
|
+
function sshExec(conn, command) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
conn.exec(command, (err, stream) => {
|
|
19
|
+
if (err)
|
|
20
|
+
return reject(err);
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
stream.on('close', (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
|
|
24
|
+
stream.on('data', (chunk) => {
|
|
25
|
+
stdout += chunk;
|
|
26
|
+
});
|
|
27
|
+
stream.stderr.on('data', (chunk) => {
|
|
28
|
+
stderr += chunk;
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function sshWriteFile(conn, path, contents, mode) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
conn.sftp((err, sftp) => {
|
|
36
|
+
if (err)
|
|
37
|
+
return reject(err);
|
|
38
|
+
sftp.writeFile(path, contents, mode ? { mode } : undefined, (writeErr) => {
|
|
39
|
+
if (writeErr)
|
|
40
|
+
return reject(writeErr);
|
|
41
|
+
// `mode` on writeFile only applies when the file is created; chmod
|
|
42
|
+
// repairs a file that already existed with looser permissions.
|
|
43
|
+
if (!mode)
|
|
44
|
+
return resolve();
|
|
45
|
+
sftp.chmod(path, mode, (chmodErr) => (chmodErr ? reject(chmodErr) : resolve()));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export async function connectCallsSfuSsh(options, deps = {}) {
|
|
51
|
+
const Client = deps.Client ?? (await loadSsh2()).Client;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const conn = new Client();
|
|
54
|
+
let hostKey = Buffer.alloc(0);
|
|
55
|
+
conn.on('ready', () => resolve({
|
|
56
|
+
hostKey,
|
|
57
|
+
exec: (command) => sshExec(conn, command),
|
|
58
|
+
writeFile: (path, contents, mode) => sshWriteFile(conn, path, contents, mode),
|
|
59
|
+
close: () => conn.end(),
|
|
60
|
+
}));
|
|
61
|
+
conn.on('error', reject);
|
|
62
|
+
conn.connect({
|
|
63
|
+
host: options.address,
|
|
64
|
+
port: options.sshPort,
|
|
65
|
+
username: options.sshUser,
|
|
66
|
+
privateKey: options.sshPrivateKey,
|
|
67
|
+
// One argument: ssh2 treats the return value as the verdict.
|
|
68
|
+
hostVerifier: (key) => {
|
|
69
|
+
hostKey = key;
|
|
70
|
+
return verifyHostKey(key, options.sshHostFingerprint);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function loadSsh2() {
|
|
76
|
+
const specifier = 'ssh2';
|
|
77
|
+
const mod = (await import(specifier));
|
|
78
|
+
const Client = mod.Client ?? mod.default?.Client;
|
|
79
|
+
if (!Client)
|
|
80
|
+
throw new Error('ssh2 did not export a Client');
|
|
81
|
+
return { Client };
|
|
82
|
+
}
|
package/dist/calls-sfu.d.ts
CHANGED
|
@@ -17,16 +17,25 @@
|
|
|
17
17
|
* specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
|
|
18
18
|
* (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
|
|
19
19
|
* file, run a command -- so a real implementation is a thin SSH/SFTP client
|
|
20
|
-
* (`packages/foundry/scripts/calls-sfu-deploy.mjs
|
|
21
|
-
*
|
|
20
|
+
* (`packages/foundry/scripts/calls-sfu-deploy.mjs`, and, as of #170, the
|
|
21
|
+
* platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
|
|
22
|
+
* here, and every test in `calls-sfu.test.ts` runs against a fake.
|
|
22
23
|
*
|
|
23
|
-
* The address
|
|
24
|
+
* The address and every LiveKit API key/secret pair are never hardcoded here
|
|
24
25
|
* ({@link CallsSfuConfig}): PR 344's review on app-template's
|
|
25
26
|
* `calls-instance` step found its first cut created three Coolify apps
|
|
26
27
|
* before it ever checked the address was known.
|
|
27
28
|
* {@link requireCallsSfuAddress} is that check, run first, before either
|
|
28
29
|
* file is written.
|
|
29
30
|
*
|
|
31
|
+
* #152: one shared instance, kept apart by *per-company* keys rather than
|
|
32
|
+
* one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
|
|
33
|
+
* own key/secret pair, and {@link livekitConfigYaml} writes all of them into
|
|
34
|
+
* `keys:`. Minting and delivering each org's pair is the platform `sfu`
|
|
35
|
+
* step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
|
|
36
|
+
* this module's -- the same "this package never invents a credential" rule
|
|
37
|
+
* the single-pair design already followed.
|
|
38
|
+
*
|
|
30
39
|
* #127 adds a second compose service, `cloudflared`, so the SFU has a public
|
|
31
40
|
* `wss://` address through its own named tunnel -- browsers on an https page
|
|
32
41
|
* cannot open a plain `ws://` connection, and 7880 was open to the whole
|
|
@@ -63,16 +72,24 @@ export declare class MissingCloudflaredTunnelTokenError extends Error {
|
|
|
63
72
|
export declare function requireCloudflaredTunnelToken(token: string | undefined | null): string;
|
|
64
73
|
/**
|
|
65
74
|
* Everything {@link applyCallsSfu} needs and this package never invents on
|
|
66
|
-
* its own: the SFU's own address (calls#15),
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* its own: the SFU's own address (calls#15), one LiveKit API key/secret pair
|
|
76
|
+
* per org with calls enabled (#152 -- a shared instance, kept apart by
|
|
77
|
+
* per-company keys, not a port range or a deployment each), and the token
|
|
78
|
+
* for the named tunnel `scripts/calls-sfu-ingress.mjs` creates. All of it
|
|
79
|
+
* comes from the secrets store, at the caller's own script -- #122's and
|
|
80
|
+
* #127's "Human steps" -- except the per-org pairs, which the platform `sfu`
|
|
81
|
+
* step mints itself (`plan/platform-sfu.ts`).
|
|
71
82
|
*/
|
|
72
83
|
export interface CallsSfuConfig {
|
|
73
84
|
address: string;
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
/**
|
|
86
|
+
* API key id -> secret, one entry per org currently enabled for calls.
|
|
87
|
+
* {@link livekitConfigYaml} writes every entry under `keys:`, sorted by key
|
|
88
|
+
* id so the same set of orgs always serializes to the same bytes -- what
|
|
89
|
+
* `calls-sfu-ssh-adapter.ts`'s `applyCallsSfuOverSession` compares, byte
|
|
90
|
+
* for byte, to decide whether LiveKit needs a restart.
|
|
91
|
+
*/
|
|
92
|
+
livekitKeys: Record<string, string>;
|
|
76
93
|
cloudflaredTunnelToken: string;
|
|
77
94
|
}
|
|
78
95
|
/**
|
|
@@ -102,8 +119,8 @@ export declare const CLOUDFLARED_ENV_PATH = "/opt/livekit/cloudflared.env";
|
|
|
102
119
|
* anywhere a supply-chain review has asked for a digest pin yet.
|
|
103
120
|
*/
|
|
104
121
|
export declare const LIVEKIT_IMAGE_REF = "livekit/livekit-server:latest";
|
|
105
|
-
/**
|
|
106
|
-
export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:
|
|
122
|
+
/** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
|
|
123
|
+
export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
|
|
107
124
|
/** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
|
|
108
125
|
export declare const LIVEKIT_SIGNALLING_PORT = 7880;
|
|
109
126
|
/** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
|
|
@@ -162,6 +179,13 @@ export declare function cloudflaredEnvFile(token: string): string;
|
|
|
162
179
|
* has one public address of its own, bridged directly rather than sitting
|
|
163
180
|
* behind NAT (calls#15's decision comment), so there is a known-good answer
|
|
164
181
|
* and no reason to make LiveKit discover it via STUN.
|
|
182
|
+
*
|
|
183
|
+
* `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
|
|
184
|
+
* (#152), sorted by key id -- never object insertion order, which
|
|
185
|
+
* JavaScript does not even guarantee is the order the platform step built
|
|
186
|
+
* the map in. An org dropped from the map (calls turned off) simply has no
|
|
187
|
+
* line here on the next apply; nothing here deletes a key, it only ever
|
|
188
|
+
* writes the set it was given.
|
|
165
189
|
*/
|
|
166
190
|
export declare function livekitConfigYaml(config: CallsSfuConfig): string;
|
|
167
191
|
/**
|
package/dist/calls-sfu.js
CHANGED
|
@@ -17,16 +17,25 @@
|
|
|
17
17
|
* specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
|
|
18
18
|
* (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
|
|
19
19
|
* file, run a command -- so a real implementation is a thin SSH/SFTP client
|
|
20
|
-
* (`packages/foundry/scripts/calls-sfu-deploy.mjs
|
|
21
|
-
*
|
|
20
|
+
* (`packages/foundry/scripts/calls-sfu-deploy.mjs`, and, as of #170, the
|
|
21
|
+
* platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
|
|
22
|
+
* here, and every test in `calls-sfu.test.ts` runs against a fake.
|
|
22
23
|
*
|
|
23
|
-
* The address
|
|
24
|
+
* The address and every LiveKit API key/secret pair are never hardcoded here
|
|
24
25
|
* ({@link CallsSfuConfig}): PR 344's review on app-template's
|
|
25
26
|
* `calls-instance` step found its first cut created three Coolify apps
|
|
26
27
|
* before it ever checked the address was known.
|
|
27
28
|
* {@link requireCallsSfuAddress} is that check, run first, before either
|
|
28
29
|
* file is written.
|
|
29
30
|
*
|
|
31
|
+
* #152: one shared instance, kept apart by *per-company* keys rather than
|
|
32
|
+
* one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
|
|
33
|
+
* own key/secret pair, and {@link livekitConfigYaml} writes all of them into
|
|
34
|
+
* `keys:`. Minting and delivering each org's pair is the platform `sfu`
|
|
35
|
+
* step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
|
|
36
|
+
* this module's -- the same "this package never invents a credential" rule
|
|
37
|
+
* the single-pair design already followed.
|
|
38
|
+
*
|
|
30
39
|
* #127 adds a second compose service, `cloudflared`, so the SFU has a public
|
|
31
40
|
* `wss://` address through its own named tunnel -- browsers on an https page
|
|
32
41
|
* cannot open a plain `ws://` connection, and 7880 was open to the whole
|
|
@@ -99,8 +108,8 @@ export const CLOUDFLARED_ENV_PATH = '/opt/livekit/cloudflared.env';
|
|
|
99
108
|
* anywhere a supply-chain review has asked for a digest pin yet.
|
|
100
109
|
*/
|
|
101
110
|
export const LIVEKIT_IMAGE_REF = 'livekit/livekit-server:latest';
|
|
102
|
-
/**
|
|
103
|
-
export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:
|
|
111
|
+
/** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
|
|
112
|
+
export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
|
|
104
113
|
/** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
|
|
105
114
|
export const LIVEKIT_SIGNALLING_PORT = 7880;
|
|
106
115
|
/** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
|
|
@@ -190,9 +199,20 @@ export function cloudflaredEnvFile(token) {
|
|
|
190
199
|
* has one public address of its own, bridged directly rather than sitting
|
|
191
200
|
* behind NAT (calls#15's decision comment), so there is a known-good answer
|
|
192
201
|
* and no reason to make LiveKit discover it via STUN.
|
|
202
|
+
*
|
|
203
|
+
* `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
|
|
204
|
+
* (#152), sorted by key id -- never object insertion order, which
|
|
205
|
+
* JavaScript does not even guarantee is the order the platform step built
|
|
206
|
+
* the map in. An org dropped from the map (calls turned off) simply has no
|
|
207
|
+
* line here on the next apply; nothing here deletes a key, it only ever
|
|
208
|
+
* writes the set it was given.
|
|
193
209
|
*/
|
|
194
210
|
export function livekitConfigYaml(config) {
|
|
195
211
|
const address = requireCallsSfuAddress(config.address);
|
|
212
|
+
const keys = Object.entries(config.livekitKeys).sort(([a], [b]) => a.localeCompare(b));
|
|
213
|
+
const keysYaml = keys.length === 0
|
|
214
|
+
? 'keys: {}\n'
|
|
215
|
+
: `keys:\n${keys.map(([apiKey, apiSecret]) => ` ${apiKey}: ${apiSecret}`).join('\n')}\n`;
|
|
196
216
|
return `port: ${LIVEKIT_SIGNALLING_PORT}
|
|
197
217
|
rtc:
|
|
198
218
|
tcp_port: ${LIVEKIT_MEDIA_TCP_PORT}
|
|
@@ -200,9 +220,7 @@ rtc:
|
|
|
200
220
|
port_range_end: 60000
|
|
201
221
|
use_external_ip: false
|
|
202
222
|
node_ip: ${address}
|
|
203
|
-
|
|
204
|
-
${config.livekitApiKey}: ${config.livekitApiSecret}
|
|
205
|
-
`;
|
|
223
|
+
${keysYaml}`;
|
|
206
224
|
}
|
|
207
225
|
async function run(host, command) {
|
|
208
226
|
const result = await host.exec(command);
|
|
@@ -210,6 +228,20 @@ async function run(host, command) {
|
|
|
210
228
|
throw new Error(`"${command}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
|
|
211
229
|
}
|
|
212
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Writes `contents` to `${path}.tmp` and renames it onto `path` with `mv`,
|
|
233
|
+
* rather than writing `path` directly -- `mv` within the same directory is a
|
|
234
|
+
* single rename syscall, so a reader of `path` (this function's own
|
|
235
|
+
* `cat`-and-compare in `calls-sfu-ssh-adapter.ts`, or LiveKit's own read at
|
|
236
|
+
* container start) only ever sees the old complete file or the new complete
|
|
237
|
+
* one, never a partial write from one apply interleaved with another's
|
|
238
|
+
* (#152 round 2 review, finding 3).
|
|
239
|
+
*/
|
|
240
|
+
async function writeFileAtomically(host, path, contents) {
|
|
241
|
+
const tmpPath = `${path}.tmp`;
|
|
242
|
+
await host.writeFile(tmpPath, contents);
|
|
243
|
+
await run(host, `mv -f ${tmpPath} ${path}`);
|
|
244
|
+
}
|
|
213
245
|
/**
|
|
214
246
|
* Writes all three files, brings LiveKit and `cloudflared` up, then narrows
|
|
215
247
|
* the VM firewall to exactly what #122's and #127's "Decided" sections name
|
|
@@ -239,7 +271,7 @@ export async function applyCallsSfu(host, config) {
|
|
|
239
271
|
requireCallsSfuAddress(config.address);
|
|
240
272
|
requireCloudflaredTunnelToken(config.cloudflaredTunnelToken);
|
|
241
273
|
await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
|
|
242
|
-
await host
|
|
274
|
+
await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
|
|
243
275
|
await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
|
|
244
276
|
await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
|
|
245
277
|
await run(host, 'ufw allow 22/tcp');
|