feat: ephemeral agent token for serve ↔ gateway auth

- serve generates random UUID on startup
- registration sends agentToken to gateway, stored in KV
- gateway injects X-Agent-Token header when proxying to agent
- serve rejects /api/* requests without valid token
- healthz remains unauthenticated
- tunnel URL is now protected — direct access returns 401

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-09 12:05:10 +00:00
parent f16e7641fd
commit d0803019b5
4 changed files with 31 additions and 9 deletions
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string): Hono { export function createApp(storageRoot: string, agentToken: string | null): Hono {
const app = new Hono(); const app = new Hono();
app.onError((_err, c) => { app.onError((_err, c) => {
@@ -37,6 +37,17 @@ export function createApp(storageRoot: string): Hono {
await next(); await next();
}); });
// ── Agent token auth (skip healthz) ───────────────────────────────
if (agentToken !== null) {
app.use("/api/*", async (c, next) => {
const token = c.req.header("X-Agent-Token");
if (token !== agentToken) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
});
}
app.get("/healthz", (c) => c.json({ ok: true })); app.get("/healthz", (c) => c.json({ ok: true }));
app.get("/api/healthz", (c) => c.json({ ok: true })); app.get("/api/healthz", (c) => c.json({ ok: true }));
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os"; import { hostname as osHostname } from "node:os";
import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { serve } from "bun"; import { serve } from "bun";
@@ -15,8 +16,8 @@ import type { ServeOptions } from "./types.js";
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev"; const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
const HEARTBEAT_INTERVAL_MS = 60_000; const HEARTBEAT_INTERVAL_MS = 60_000;
export function startServer(storageRoot: string, options: ServeOptions): void { export function startServer(storageRoot: string, options: ServeOptions, agentToken: string | null): void {
const app = createApp(storageRoot); const app = createApp(storageRoot, agentToken);
const server = serve({ const server = serve({
fetch: app.fetch, fetch: app.fetch,
@@ -93,7 +94,8 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
} }
const options = parsed.value; const options = parsed.value;
startServer(storageRoot, options); const agentToken = options.noTunnel ? null : randomUUID();
startServer(storageRoot, options, agentToken);
if (options.noTunnel) { if (options.noTunnel) {
printCliLine("tunnel disabled (--no-tunnel)"); printCliLine("tunnel disabled (--no-tunnel)");
@@ -120,6 +122,7 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
options.name, options.name,
tunnel.url, tunnel.url,
options.gatewaySecret, options.gatewaySecret,
agentToken!,
); );
if (registered) { if (registered) {
printCliLine(`registered with gateway as "${options.name}"`); printCliLine(`registered with gateway as "${options.name}"`);
@@ -131,6 +134,7 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
options.name, options.name,
tunnel.url, tunnel.url,
options.gatewaySecret, options.gatewaySecret,
agentToken!,
HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS,
); );
@@ -39,12 +39,13 @@ export async function registerWithGateway(
name: string, name: string,
tunnelUrl: string, tunnelUrl: string,
secret: string, secret: string,
agentToken: string,
): Promise<boolean> { ): Promise<boolean> {
try { try {
const resp = await fetch(`${gatewayUrl}/register`, { const resp = await fetch(`${gatewayUrl}/register`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, url: tunnelUrl, secret }), body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
}); });
if (!resp.ok) { if (!resp.ok) {
const body = await resp.text(); const body = await resp.text();
@@ -78,9 +79,10 @@ export function startHeartbeat(
name: string, name: string,
tunnelUrl: string, tunnelUrl: string,
secret: string, secret: string,
agentToken: string,
intervalMs: number, intervalMs: number,
): ReturnType<typeof setInterval> { ): ReturnType<typeof setInterval> {
return setInterval(() => { return setInterval(() => {
registerWithGateway(gatewayUrl, name, tunnelUrl, secret).catch(() => {}); registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
}, intervalMs); }, intervalMs);
} }
+8 -3
View File
@@ -12,6 +12,7 @@ type Env = {
type EndpointRecord = { type EndpointRecord = {
name: string; name: string;
url: string; url: string;
agentToken: string;
registeredAt: number; registeredAt: number;
lastHeartbeat: number; lastHeartbeat: number;
}; };
@@ -44,8 +45,8 @@ app.get("/healthz", (c) => c.json({ ok: true }));
// ── Register / heartbeat ──────────────────────────────────────────── // ── Register / heartbeat ────────────────────────────────────────────
app.post("/register", async (c) => { app.post("/register", async (c) => {
const body = await c.req.json<{ name?: string; url?: string; secret?: string }>(); const body = await c.req.json<{ name?: string; url?: string; secret?: string; agentToken?: string }>();
const { name, url, secret } = body; const { name, url, secret, agentToken } = body;
if (!name || !url) { if (!name || !url) {
return c.json({ error: "name and url required" }, 400); return c.json({ error: "name and url required" }, 400);
@@ -60,6 +61,7 @@ app.post("/register", async (c) => {
const record: EndpointRecord = { const record: EndpointRecord = {
name, name,
url: url.replace(/\/+$/, ""), // strip trailing slash url: url.replace(/\/+$/, ""), // strip trailing slash
agentToken: agentToken ?? existing?.agentToken ?? "",
registeredAt: existing?.registeredAt ?? now, registeredAt: existing?.registeredAt ?? now,
lastHeartbeat: now, lastHeartbeat: now,
}; };
@@ -119,9 +121,12 @@ app.all("/api/:agent/*", async (c) => {
const pathAfterAgent = url.pathname.replace(`/api/${agent}`, ""); const pathAfterAgent = url.pathname.replace(`/api/${agent}`, "");
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`; const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
// Forward headers (skip host)
const headers = new Headers(c.req.raw.headers); const headers = new Headers(c.req.raw.headers);
headers.delete("host"); headers.delete("host");
headers.delete("Authorization"); // don't forward dashboard key to agent
if (record.agentToken) {
headers.set("X-Agent-Token", record.agentToken);
}
try { try {
const resp = await fetch(targetUrl, { const resp = await fetch(targetUrl, {