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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user