130 lines
5.3 KiB
TypeScript
Executable File
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>;
|
|
}
|