Files
wristcode/gateway/src/cli.ts
T
gib 42b6a4920c
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Gateway: fallback routes per host, for the laptop's VPN address
2026-11-01 13:20:02 -05:00

130 lines
5.3 KiB
TypeScript
Executable File

#!/usr/bin/env bun
// Operator commands. They edit the same SQLite file the running gateway reads, so changes apply
// within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode <command>`.
import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts";
import { hostUrls } from "./hub.ts";
import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts";
const USAGE = `wristcode <command>
host add <url> <pairing-credential> [--label <name>] [--alt-url <url>]...
Connect a T3 Code server. Make the credential on that host with:
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
Run it again with a fresh credential to renew the gateway's token.
--alt-url adds a fallback route to the same server, such as a VPN address, tried in order
when <url> does not answer. Repeat it for more.
host list
host remove <id or label>
pair
Print a 6 digit code to enter on the watch. Good for 5 minutes.
device list
device revoke <id or name>`;
const PAIRING_CODE_TTL_MS = 5 * 60_000;
const db = openDb(loadConfig().dbPath);
const [group, command, ...rest] = process.argv.slice(2);
try {
await run();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
db.close();
}
async function run() {
switch (`${group} ${command ?? ""}`.trim()) {
case "host add":
return hostAdd(rest);
case "host list":
for (const host of db.hosts.list()) {
console.log(`${host.label}\t${hostUrls(host).join(" ")}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`);
}
return;
case "host remove":
if (!rest[0]) throw new Error(USAGE);
console.log(db.hosts.remove(rest[0]) ? "Removed." : "No such host.");
return;
case "pair": {
const code = String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1_000_000).padStart(6, "0");
db.pairing.create(code, PAIRING_CODE_TTL_MS);
console.log(`Enter this code on the watch within 5 minutes: ${code}`);
return;
}
case "device list":
for (const device of db.devices.list()) {
const state = device.revoked_at ? "revoked" : device.apns_token ? `push ${device.apns_env}` : "no push";
console.log(`${device.name}\t${device.id}\t${state}\tlast seen ${device.last_seen_at ?? "never"}`);
}
return;
case "device revoke":
if (!rest[0]) throw new Error(USAGE);
console.log(`Revoked ${db.devices.revoke(rest[0])} device(s).`);
return;
default:
console.log(USAGE);
}
}
/** Exchanges a T3 pairing credential for a bearer session and saves the host. */
async function hostAdd(args: string[]) {
const positional: string[] = [];
const altUrls: string[] = [];
let label: string | undefined;
for (let index = 0; index < args.length; index++) {
const arg = args[index]!;
if (arg === "--label") label = args[++index];
else if (arg === "--alt-url") altUrls.push(new URL(args[++index] ?? "").origin);
else positional.push(arg);
}
const [rawUrl, credential] = positional;
if (!rawUrl || !credential) throw new Error(USAGE);
const url = new URL(rawUrl).origin;
const descriptor = await getJson<EnvironmentDescriptor>(`${url}/.well-known/t3/environment`);
if ((descriptor.orchestrationProtocolVersion ?? 1) !== ORCHESTRATION_PROTOCOL_VERSION) {
throw new Error(`${url} speaks orchestration protocol ${descriptor.orchestrationProtocolVersion ?? 1}, this gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}.`);
}
const response = await fetch(`${url}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: credential,
subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
scope: "orchestration:read orchestration:operate",
client_label: "WristCode gateway",
client_device_type: "bot",
}),
});
if (!response.ok) throw new Error(`${url} refused the pairing credential (${response.status}): ${await response.text()}`);
const token = (await response.json()) as { access_token: string; expires_in?: number; scope?: string };
const scopes = token.scope?.split(" ") ?? [];
if (!scopes.includes("orchestration:read") || !scopes.includes("orchestration:operate")) {
throw new Error(`The credential only granted "${token.scope}". Make one with orchestration:read and orchestration:operate.`);
}
db.hosts.upsert({
id: descriptor.environmentId,
label: label ?? descriptor.label,
url,
alt_urls: altUrls.length > 0 ? JSON.stringify(altUrls) : null,
environment_id: descriptor.environmentId,
token: token.access_token,
token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null,
});
console.log(`Added ${label ?? descriptor.label} (T3 ${descriptor.serverVersion}). The gateway connects within 15 seconds.`);
}
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json() as Promise<T>;
}