/hook/record and /hook/approve accepted anything that reached the port. That is not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so anyone who could reach the tunnel could forge timeline events and answer approval prompts. Both endpoints now require a 32-byte secret from $GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in flight is 403'd mid-session), compared in constant time before the body is read, as an x-glance-hook header or a ?k= parameter. Requests carrying x-forwarded-* are refused outright: a local hook process never sends them and a tunnelled caller always does. The check applies to /hook/* only, so the dashboard is unaffected. While wiring that up: the 13 passive `type: "http"` hooks could never have worked. Grok Build's http runner rejects every scheme but https, then resolves the host and blocks private/link-local/CGNAT addresses (validate_hook_url + is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a hook target - and it sends no header but Content-Type, so such a hook could not authenticate anyway. They were failing validation silently on every event. All of them are now command hooks running bin/glance-record.mjs, which costs a Node start and can present the secret. hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs (npm run build, glance sync-hooks). It creates the secret, derives the approval hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses to write a hook that cannot fire: bad type, non-positive timeout, non-https http URL, missing bin/ script, or a leftover placeholder. A template that embeds the token makes the output 0600 with a warning. Fail-open is unchanged: a missing, stale or rejected secret degrades to "no telemetry", and glance-approve.mjs still allows on every error path. glance status warns when the on-disk secret no longer matches the daemon's. Validated with the e2e suite (190 checks, including no-token/wrong-token/ same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the recorder's fail-open paths) and a clean npm run build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
8.1 KiB
JavaScript
Executable File
251 lines
8.1 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* grok-glance CLI.
|
|
*
|
|
* Privileged operations (minting enrollment codes, revoking devices) are authenticated with
|
|
* a local admin token read from $GLANCE_HOME/admin.token, not by "is this request from
|
|
* localhost". That distinction matters: `tailscale serve` proxies remote traffic to
|
|
* 127.0.0.1, so the daemon cannot tell a local caller from a tunnelled one by address alone.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
PLUGIN_ROOT,
|
|
baseUrl,
|
|
glanceHome,
|
|
isDaemonUp,
|
|
readConfig,
|
|
sleep,
|
|
} from "./glance-lib.mjs";
|
|
|
|
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
|
const cfg = readConfig();
|
|
const cmd = process.argv[2] ?? "status";
|
|
const args = process.argv.slice(3);
|
|
|
|
function adminToken() {
|
|
try {
|
|
return fs.readFileSync(path.join(glanceHome(), "admin.token"), "utf8").trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function api(pathname, { method = "GET", body, admin = false } = {}) {
|
|
const headers = { "content-type": "application/json" };
|
|
if (admin) {
|
|
const token = adminToken();
|
|
if (!token) {
|
|
throw new Error("no admin token found - is the daemon running? try `glance up`");
|
|
}
|
|
headers["x-glance-admin"] = token;
|
|
}
|
|
const res = await fetch(`${baseUrl(cfg)}${pathname}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await res.text();
|
|
let data = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = null;
|
|
}
|
|
if (!res.ok) throw new Error(data?.error ?? `${res.status} ${res.statusText}`);
|
|
return data;
|
|
}
|
|
|
|
function requireBuild() {
|
|
if (!fs.existsSync(SERVER_ENTRY)) {
|
|
console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function ensureUp() {
|
|
if (await isDaemonUp(cfg)) return true;
|
|
requireBuild();
|
|
const home = glanceHome();
|
|
fs.mkdirSync(home, { recursive: true });
|
|
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
|
|
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
|
detached: true,
|
|
stdio: ["ignore", logFd, logFd],
|
|
});
|
|
child.unref();
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(200);
|
|
if (await isDaemonUp(cfg, 300)) return true;
|
|
}
|
|
console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`);
|
|
return false;
|
|
}
|
|
|
|
switch (cmd) {
|
|
case "serve":
|
|
case "start": {
|
|
requireBuild();
|
|
await import(SERVER_ENTRY);
|
|
break;
|
|
}
|
|
|
|
case "up": {
|
|
if (await ensureUp()) console.log(`grok-glance running on ${baseUrl(cfg)}`);
|
|
else process.exit(1);
|
|
break;
|
|
}
|
|
|
|
case "stop": {
|
|
if (!(await isDaemonUp(cfg))) {
|
|
console.log("not running");
|
|
break;
|
|
}
|
|
await api("/local/shutdown", { method: "POST", admin: true });
|
|
console.log("stopped");
|
|
break;
|
|
}
|
|
|
|
case "status": {
|
|
if (!(await isDaemonUp(cfg))) {
|
|
console.log(`grok-glance: not running (port ${cfg.port})`);
|
|
console.log("start it with: glance up");
|
|
break;
|
|
}
|
|
const s = await api("/local/status", { admin: true });
|
|
console.log(`grok-glance ${s.version} on ${baseUrl(cfg)}`);
|
|
console.log(` public origin : ${s.origin ?? "(not configured - see README)"}`);
|
|
console.log(` rp id : ${s.rpId ?? "(not configured)"}`);
|
|
console.log(` devices : ${s.devices}`);
|
|
console.log(` approval mode : ${s.approval.mode}`);
|
|
console.log(` watchers : ${s.watchers}`);
|
|
console.log(` sessions : ${s.sessions}`);
|
|
console.log(` events kept : ${s.events}`);
|
|
if (s.hookAuthOk === false) {
|
|
console.log(
|
|
"\n ! hook auth mismatch: $GLANCE_HOME/hook.secret no longer matches what the daemon" +
|
|
"\n loaded, so events are being dropped. Restart it: glance stop && glance up",
|
|
);
|
|
}
|
|
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
|
break;
|
|
}
|
|
|
|
case "enroll": {
|
|
if (!(await ensureUp())) process.exit(1);
|
|
const out = await api("/local/enroll", { method: "POST", admin: true });
|
|
console.log("\n Open this on your phone:\n");
|
|
console.log(` ${out.url}\n`);
|
|
console.log(` Enrollment code: ${out.code}`);
|
|
console.log(` Valid for: ${Math.round(out.expiresInMs / 60000)} minutes (single use)\n`);
|
|
if (!out.originConfigured) {
|
|
console.log(" Note: no public origin configured yet, so the URL above is localhost.");
|
|
console.log(" Set one up first (see README), e.g.:\n");
|
|
console.log(" tailscale serve --bg 127.0.0.1:" + cfg.port);
|
|
console.log(" glance set-origin https://<your-box>.<tailnet>.ts.net\n");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "set-origin": {
|
|
const origin = args[0];
|
|
if (!origin) {
|
|
console.error("usage: glance set-origin https://your-box.tailnet.ts.net");
|
|
process.exit(1);
|
|
}
|
|
if (!(await ensureUp())) process.exit(1);
|
|
const out = await api("/local/origin", { method: "POST", admin: true, body: { origin } });
|
|
console.log(`origin : ${out.origin}`);
|
|
console.log(`rp id : ${out.rpId}`);
|
|
console.log("\nEnrolled devices are bound to the rp id. Changing it invalidates them.");
|
|
break;
|
|
}
|
|
|
|
case "devices": {
|
|
if (!(await isDaemonUp(cfg))) {
|
|
console.error("not running");
|
|
process.exit(1);
|
|
}
|
|
const out = await api("/local/devices", { admin: true });
|
|
if (!out.devices.length) {
|
|
console.log("no devices enrolled - run: glance enroll");
|
|
break;
|
|
}
|
|
for (const d of out.devices) {
|
|
console.log(`${d.id.slice(0, 16)}… ${d.label.padEnd(24)} added ${new Date(d.createdAt).toISOString().slice(0, 10)} last seen ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "revoke": {
|
|
if (!args[0]) {
|
|
console.error("usage: glance revoke <device-id-prefix>");
|
|
process.exit(1);
|
|
}
|
|
const out = await api("/local/devices/revoke", {
|
|
method: "POST",
|
|
admin: true,
|
|
body: { idPrefix: args[0] },
|
|
});
|
|
console.log(`revoked ${out.revoked} device(s)`);
|
|
break;
|
|
}
|
|
|
|
case "approval": {
|
|
const mode = args[0];
|
|
if (!["off", "risky", "all"].includes(mode)) {
|
|
console.error("usage: glance approval <off|risky|all>");
|
|
process.exit(1);
|
|
}
|
|
const out = await api("/local/approval", { method: "POST", admin: true, body: { mode } });
|
|
console.log(`approval mode: ${out.mode}`);
|
|
break;
|
|
}
|
|
|
|
case "sync-hooks": {
|
|
// hooks.json is generated, not edited: regenerate it from hooks/hooks.template.json.
|
|
// The port is not baked into it any more — the hook scripts read config.json themselves —
|
|
// so the thing this actually refreshes is the approval gate's timeout, plus the hook
|
|
// secret if it has gone missing.
|
|
const { status } = spawnSync(
|
|
process.execPath,
|
|
[path.join(PLUGIN_ROOT, "scripts", "gen-hooks.mjs")],
|
|
{ stdio: "inherit" },
|
|
);
|
|
if (status !== 0) process.exit(status ?? 1);
|
|
if (await isDaemonUp(cfg)) {
|
|
console.log("restart the daemon to pick up config changes: glance stop && glance up");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "logs": {
|
|
const file = path.join(glanceHome(), "daemon.log");
|
|
if (!fs.existsSync(file)) {
|
|
console.log("no log yet");
|
|
break;
|
|
}
|
|
process.stdout.write(fs.readFileSync(file, "utf8").split("\n").slice(-60).join("\n") + "\n");
|
|
break;
|
|
}
|
|
|
|
default:
|
|
console.log(`grok-glance - glance at Grok Build from your phone
|
|
|
|
glance up start the daemon in the background
|
|
glance serve run it in the foreground
|
|
glance stop stop it
|
|
glance status show what is running
|
|
glance enroll mint a one-time code to enrol a phone
|
|
glance set-origin <url> set the public https origin (and webauthn rp id)
|
|
glance devices list enrolled devices
|
|
glance revoke <id-prefix> revoke a device
|
|
glance approval <off|risky|all> remote approval policy
|
|
glance sync-hooks regenerate hooks/hooks.json from the template
|
|
glance logs tail the daemon log
|
|
`);
|
|
}
|