Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions control-plane/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ declare global {
/** Same routing-key secret name/shape as the main app's own src/env.d.ts (#7667's PagerDuty mirror) --
* grants the ability to trigger a real page, so it's a secret here too, not a plain var. */
PAGERDUTY_ROUTING_KEY?: string;
/** This hosted fleet's OWN GitHub App webhook secret (#7181) -- a SEPARATE value from the main app's
* ORB_GITHUB_WEBHOOK_SECRET (a different physical service). Unset ⇒ POST /v1/orb/webhook fails every
* delivery closed (orb-webhook-router.ts). Genuinely sensitive: whoever holds it can forge a webhook. */
ORB_WEBHOOK_SECRET?: string;
}
}

Expand Down
76 changes: 72 additions & 4 deletions control-plane/src/http-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@
// `POST /v1/tenants` also accepts an optional `schedule` field (#7182), valid only for `product: "ams"`:
// configures the new tenant's cron-wake cadence at creation time. ams-wake.ts's `scheduled()`-triggered
// handler is what actually reads and acts on it later -- this route only validates and stores it.
//
// `POST /v1/tenants` also accepts an optional `orbInstallationId` field (#7181), valid only for
// `product: "orb"`: the GitHub App installation this tenant's hosted container answers webhooks for.
// `POST /v1/orb/webhook` (below) is the actual routing endpoint that reads it back via the registry's
// installation-ID index -- this route only validates, checks for a conflicting claim, and stores it.
import { Hono } from "hono";
import { normalizeSharedSecret, verifyBearer } from "./auth.js";
import { routeOrbWebhook, type RouterNamespaceLike } from "./orb-webhook-router.js";
import {
deprovisionTenant,
provisionTenant,
Expand All @@ -34,10 +40,22 @@ export type TenantHttpAppDeps = {
* rather than silently accepting an unauthenticated caller. */
adminToken: string | undefined;
pagerDuty?: ProvisioningPagerDutyOptions;
/** ORB's tenant container binding + the hosted fleet's own GitHub App webhook secret (#7181) -- wired into
* `POST /v1/orb/webhook` below via orb-webhook-router.ts. Optional so existing callers (and every test that
* doesn't exercise webhook routing) don't need to supply a binding they never use; `webhookSecret` being
* unset already fails that route closed on its own (see orb-webhook-router.ts), same as `adminToken`. */
orbWebhookBinding?: RouterNamespaceLike;
orbWebhookSecret?: string;
};

function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "state" | "amsSchedule">): Record<string, unknown> {
return { tenant: record.tenant, product: record.product, state: record.state, ...(record.amsSchedule ? { amsSchedule: record.amsSchedule } : {}) };
function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "state" | "amsSchedule" | "orbInstallationId">): Record<string, unknown> {
return {
tenant: record.tenant,
product: record.product,
state: record.state,
...(record.amsSchedule ? { amsSchedule: record.amsSchedule } : {}),
...(record.orbInstallationId !== undefined ? { orbInstallationId: record.orbInstallationId } : {}),
};
}

/** The only `command` names #7182's hosted entry point (loopover-miner-hosted) actually dispatches --
Expand Down Expand Up @@ -68,6 +86,18 @@ function parseScheduleRequest(value: unknown): AmsCycleSchedule | string | undef
return { command, args: Array.isArray(args) ? args : [], intervalMs, nextDueAt: new Date().toISOString() };
}

/** Validated body of `POST /v1/tenants`'s optional `orbInstallationId` field (#7181): a GitHub App
* installation ID, always a positive integer (GitHub's own ID space). `undefined` input (the field omitted)
* is valid -- an ORB tenant with no installation linked yet simply never receives a routed webhook, which is
* a legitimate state during onboarding, not an error. */
function parseOrbInstallationId(value: unknown): number | string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
return "orbInstallationId must be a positive integer";
}
return value;
}

/** Validated body of `POST /v1/tenants/rollout` (#4898): an explicit tenant-name list (no percentage/canary
* selector — no such primitive exists elsewhere in this codebase to build on) plus the version to pin.
* `pinnedVersion: null` is an explicit unpin (revert to the release channel's default). Scoped to a single
Expand Down Expand Up @@ -114,12 +144,17 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
app.post("/v1/tenants", async (c) => {
const body: unknown = await c.req.json().catch(() => null);
if (body === null || typeof body !== "object") return c.json({ error: "invalid_json" }, 400);
const { name, product, schedule: scheduleInput } = body as Record<string, unknown>;
const { name, product, schedule: scheduleInput, orbInstallationId: orbInstallationIdInput } = body as Record<string, unknown>;
if (typeof name !== "string" || !name.trim()) return c.json({ error: "invalid_request", message: "name is required" }, 400);
if (typeof product !== "string" || !product.trim()) return c.json({ error: "invalid_request", message: "product is required" }, 400);
const schedule = parseScheduleRequest(scheduleInput);
if (typeof schedule === "string") return c.json({ error: "invalid_request", message: schedule }, 400);
if (schedule && product !== "ams") return c.json({ error: "invalid_request", message: 'schedule is only valid for product "ams"' }, 400);
const orbInstallationId = parseOrbInstallationId(orbInstallationIdInput);
if (typeof orbInstallationId === "string") return c.json({ error: "invalid_request", message: orbInstallationId }, 400);
if (orbInstallationId !== undefined && product !== "orb") {
return c.json({ error: "invalid_request", message: 'orbInstallationId is only valid for product "orb"' }, 400);
}

// Not idempotent by design (tenant-client.ts's own doc comment: "a create is not idempotent, so it must
// not be silently re-sent") -- a currently-active tenant of the same name *and product* is a real conflict,
Expand All @@ -128,9 +163,30 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
const existing = await deps.registry.get(name, product);
if (existing && existing.state !== "torn down") return c.json({ error: "tenant_already_exists" }, 409);

// A GitHub installation ID must resolve to exactly one hosted container (#7181's routing depends on this
// being unambiguous) -- reject before ever provisioning anything, same posture as the name+product conflict
// check just above.
if (orbInstallationId !== undefined) {
const conflicting = await deps.registry.getByOrbInstallationId(orbInstallationId);
if (conflicting && conflicting.state !== "torn down") {
return c.json(
{ error: "installation_already_claimed", message: `installation ${orbInstallationId} is already claimed by tenant "${conflicting.tenant.name}"` },
409,
);
}
}

const result = await provisionTenant({ name }, product, deps.driver, deps.pagerDuty ?? {});
const now = new Date().toISOString();
const record: TenantRegistryRecord = { tenant: result.tenant, product: result.product, state: result.state, createdAt: now, updatedAt: now, ...(schedule ? { amsSchedule: schedule } : {}) };
const record: TenantRegistryRecord = {
tenant: result.tenant,
product: result.product,
state: result.state,
createdAt: now,
updatedAt: now,
...(schedule ? { amsSchedule: schedule } : {}),
...(orbInstallationId !== undefined ? { orbInstallationId } : {}),
};
await deps.registry.upsert(record);
return c.json(safeRecord(record), 201);
});
Expand Down Expand Up @@ -192,5 +248,17 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
return c.json(safeRecord(result));
});

// #7181: routes an incoming GitHub webhook to the hosted ORB tenant it belongs to. Deliberately OUTSIDE the
// `/v1/tenants/*` admin-bearer middleware above -- GitHub authenticates a webhook via its own HMAC signature
// (orb-webhook-router.ts verifies it independently), not this service's admin token. An unset
// `orbWebhookBinding` (a test harness that never exercises this route, or a real deployment mid-rollout)
// fails closed with 503 here, matching `adminToken`'s own "unconfigured ⇒ 503" convention above -- a missing
// binding is a deployment-config gap, not something `routeOrbWebhook` (which assumes it always has one) or
// its 401/502 error shapes should have to represent.
app.post("/v1/orb/webhook", async (c) => {
if (!deps.orbWebhookBinding) return c.json({ error: "service_not_configured" }, 503);
return routeOrbWebhook({ binding: deps.orbWebhookBinding, registry: deps.registry, webhookSecret: deps.orbWebhookSecret }, c.req.raw);
});

return app;
}
6 changes: 6 additions & 0 deletions control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,9 @@ export {
type WakeNamespaceLike,
type WakeStubLike,
} from "./ams-wake.js";
export {
routeOrbWebhook,
type OrbWebhookRouterConfig,
type RouterNamespaceLike,
type RouterStubLike,
} from "./orb-webhook-router.js";
121 changes: 121 additions & 0 deletions control-plane/src/orb-webhook-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Request-time routing of incoming GitHub webhook deliveries to the correct hosted ORB tenant's container
// (#7181, part of #7173's shared control-plane). ORB's central GitHub App delivers every installation's
// webhooks to ONE configured URL -- self-host has exactly one deployment per installation already, so nothing
// routes there today; a hosted, multi-tenant fleet needs this thin layer in front of it to find which
// container an incoming delivery actually belongs to. Verification/parsing/dispatch all happen HERE (this
// service has no D1 of its own, unlike the main app's src/orb/webhook.ts -- see http-app.ts's own header
// comment on why credentials/state live in this Worker's KV registry instead of the main app's database), then
// the verified, unmodified request is proxied straight through to that tenant's own container, which runs the
// SAME self-host webhook-handling code unmodified and re-verifies independently -- this layer changes WHERE a
// webhook gets dispatched, not how it's authenticated (mirrors the main app's own handleOrbWebhook contract).
import type { Product } from "./tenant-provisioning-driver.js";
import type { TenantRegistry } from "./tenant-registry.js";

/** The slice of a real Container DO's RPC surface this module actually calls -- a SEPARATE small local
* interface from container-driver.ts's `ContainerStubLike` (that one starts/stops/tracks provisioning; this
* one only proxies an HTTP request) and ams-wake.ts's `WakeStubLike` (that one starts a one-shot CLI run and
* polls for completion; ORB's container is a persistent HTTP server, so a plain `fetch()` is enough --
* @cloudflare/containers' `Container.fetch()` already starts the container if it's asleep and waits for its
* `defaultPort` to be ready before resolving, so "wake if asleep" needs no extra code here). Mirrors this
* package's established "local interface, no SDK import" convention. */
export type RouterStubLike = {
fetch(request: Request): Promise<Response>;
};

export type RouterNamespaceLike = {
getByName(name: string): RouterStubLike;
};

export type OrbWebhookRouterConfig = {
binding: RouterNamespaceLike;
registry: TenantRegistry;
/** The hosted fleet's own GitHub App webhook secret -- a control-plane Worker secret, independent of the
* main app's `ORB_GITHUB_WEBHOOK_SECRET` (a different physical service, same verification shape). Absent/
* blank ⇒ every delivery fails closed with 401, matching http-app.ts's own `adminToken` convention and the
* main app's own "inert until the secret is injected" comment on this exact check. */
webhookSecret: string | undefined;
};

/** Same `${product}:${name}` composite container-driver.ts's own `instanceNameFor` derives -- duplicated (not
* imported) for the same reason ams-wake.ts's own copy is: this module has no `TenantProvisioningRequest` to
* construct, just a name/product pair already in hand from the registry lookup below. */
function instanceNameFor(name: string, product: Product): string {
return `${product}:${name}`;
}

/** Verifies a GitHub webhook's `x-hub-signature-256` HMAC-SHA256 against the raw request body -- the exact
* same algorithm as the main app's `src/utils/crypto.ts#verifyGitHubSignature` (this package can't import
* that file; it's a separate workspace with no dependency on the main app), duplicated locally rather than
* published as a shared package purely for this one call site. Timing-safe comparison so a malformed/invalid
* signature can't leak byte-by-byte match information through response-time differences. */
async function verifyGitHubSignature(rawBody: string, signatureHeader: string | null, secret: string | undefined): Promise<boolean> {
if (!signatureHeader?.startsWith("sha256=")) return false;
if (!secret) return false;

const expectedHex = signatureHeader.slice("sha256=".length);
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));

// `actualBytes` comes straight from our own HMAC computation -- always a well-formed byte array, never in
// need of the malformed-hex handling `hexToBytes` exists for. Only `expectedHex` (attacker-controlled, off
// the request header) can ever be malformed, so it's the only side parsed through that nullable path.
return timingSafeEqualBytes(new Uint8Array(signature), expectedHex);
}

function timingSafeEqualBytes(actualBytes: Uint8Array, expectedHex: string): boolean {
const expectedBytes = hexToBytes(expectedHex);
if (!expectedBytes || actualBytes.length !== expectedBytes.length) return false;
let result = 0;
for (let index = 0; index < actualBytes.length; index += 1) result |= actualBytes[index]! ^ expectedBytes[index]!;
return result === 0;
}

function hexToBytes(hex: string): Uint8Array | null {
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
const bytes = new Uint8Array(hex.length / 2);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
return bytes;
}

/** Routes one incoming webhook `Request` to its owning ORB tenant's container, or answers an error itself if
* it can't. `request` is consumed for signature verification (its raw body text) but a `.clone()` taken
* BEFORE that read is what actually gets forwarded -- the tenant's container re-verifies the same signature
* against the same untouched body, so a body-reconstruction bug here can't silently diverge from what GitHub
* actually signed. */
export async function routeOrbWebhook(config: OrbWebhookRouterConfig, request: Request): Promise<Response> {
const forwardRequest = request.clone();
const rawBody = await request.text();
const signature = request.headers.get("x-hub-signature-256");

const verified = await verifyGitHubSignature(rawBody, signature, config.webhookSecret);
if (!verified) return Response.json({ error: "invalid_signature" }, { status: 401 });

let payload: { installation?: { id?: unknown } };
try {
payload = JSON.parse(rawBody) as { installation?: { id?: unknown } };
} catch {
return Response.json({ error: "invalid_json" }, { status: 400 });
}

const installationId = payload.installation?.id;
if (typeof installationId !== "number") {
return Response.json({ error: "missing_installation_id" }, { status: 400 });
}

const record = await config.registry.getByOrbInstallationId(installationId);
if (!record || record.product !== "orb" || record.state !== "active") {
return Response.json({ error: "unknown_installation" }, { status: 404 });
}

try {
const stub = config.binding.getByName(instanceNameFor(record.tenant.name, record.product));
// Cloudflare's own `Request<Cf, ...>` generic (carrying edge metadata this module never reads) infers
// differently between Hono's `c.req.raw` and `@cloudflare/containers`' own `Container.fetch` signature
// under `cf:typecheck`'s real workers-types -- a structural, not a runtime, mismatch; the cast is scoped to
// exactly this one boundary, same "local interface, no SDK type import" posture as ContainerStubLike's own
// header comment describes for the rest of this package.
return await stub.fetch(forwardRequest as unknown as Request);
} catch {
return Response.json({ error: "container_unreachable" }, { status: 502 });
}
}
Loading