diff --git a/.changeset/tunnel-reconciler.md b/.changeset/tunnel-reconciler.md new file mode 100644 index 000000000..bd405a2ec --- /dev/null +++ b/.changeset/tunnel-reconciler.md @@ -0,0 +1,44 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add a `@cloudflare/sandbox/tunnels` entrypoint with helpers for sweeping abandoned named tunnels and orphaned DNS records from your Cloudflare account. Useful for long-lived deployments where Durable Object cleanup may not run before tunnel resources are abandoned (e.g. DO eviction, crashes, missed `destroy()` calls). Designed to run from a cron-triggered Worker. + +```ts +import { createScheduledTunnelCleanupHandler } from '@cloudflare/sandbox/tunnels'; + +export default { + scheduled: createScheduledTunnelCleanupHandler({ + staleAfterMs: 24 * 60 * 60_000 + }) +}; +``` + +```jsonc +{ + "triggers": { + "crons": ["0 3 * * *"] + } +} +``` + +The handler reads `CLOUDFLARE_API_TOKEN` from env at run time and is a no-op when the token is missing. It uses `CLOUDFLARE_TUNNEL_ACCOUNT_ID` or `CLOUDFLARE_ACCOUNT_ID` when present, otherwise it infers the account from single-account tokens. `CLOUDFLARE_ZONE_ID` is optional and is inferred when unambiguous; without a resolved zone ID, cleanup still deletes stale tunnels but skips DNS-record cleanup. Workers with unrelated cron jobs can call the returned handler from their own cron dispatch: + +```ts +const tunnelCleanup = createScheduledTunnelCleanupHandler({ + staleAfterMs: 24 * 60 * 60_000 +}); + +export default { + async scheduled(controller, env, ctx) { + switch (controller.cron) { + case '0 3 * * *': + return tunnelCleanup(controller, env, ctx); + case '*/5 * * * *': + ctx.waitUntil(runUnrelatedJob(env)); + } + } +}; +``` + +Cleanup only deletes tunnels tagged by this SDK and refuses to delete any tunnel that is missing its `metadata.sandboxId` tag, so a misconfigured token can't wipe resources created by other tools. The lower-level `sweepStale`, `listSandboxTunnels`, and `listSandboxDNSRecords` helpers are also exported for one-off audits and custom cleanup workflows. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 31b51f5b6..f7f7c86a6 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -103,6 +103,11 @@ "types": "./dist/bridge/index.d.ts", "import": "./dist/bridge/index.js", "require": "./dist/bridge/index.js" + }, + "./tunnels": { + "types": "./dist/tunnels/index.d.ts", + "import": "./dist/tunnels/index.js", + "require": "./dist/tunnels/index.js" } }, "keywords": [], diff --git a/packages/sandbox/src/tunnels/cloudflare-api.ts b/packages/sandbox/src/tunnels/cloudflare-api.ts index 95e83a1a6..3eb9567ce 100644 --- a/packages/sandbox/src/tunnels/cloudflare-api.ts +++ b/packages/sandbox/src/tunnels/cloudflare-api.ts @@ -16,16 +16,7 @@ * stops two sandboxes from racing on the same hostname. */ -const API_BASE = 'https://api.cloudflare.com/client/v4'; - -/** Cloudflare's standard envelope around every response. */ -interface CloudflareResponse { - success: boolean; - result?: T; - errors?: Array<{ code?: number; message?: string }>; -} - -type Fetcher = typeof fetch; +import { API_BASE, cfRequest, type Fetcher } from './request'; interface BaseArgs { token: string; @@ -44,96 +35,6 @@ export interface TunnelMetadata { port: number; } -interface RequestOptions { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - body?: unknown; - /** Treat these HTTP statuses as success and skip envelope parsing. */ - acceptStatuses?: number[]; - /** - * Per-request timeout in milliseconds. Defaults to `DEFAULT_TIMEOUT_MS`. - * Without a timeout a hung Cloudflare call wedges the per-port lock in - * `rpc-target.ts` indefinitely, which then blocks every subsequent - * `get(port)` / `destroy(port)` on that port. The shared - * `#zoneNamePromise` makes the impact span every port for named - * tunnels. - */ - timeoutMs?: number; -} - -/** - * Default request timeout. Cloudflare API P99 latency is well under - * this; values much smaller risk false positives on cold control-plane - * paths (e.g. first `cfd_tunnel` POST in a new account). - */ -const DEFAULT_TIMEOUT_MS = 10_000; - -/** - * Internal request helper. Centralises auth header, JSON encoding, - * timeout enforcement, and envelope unwrapping so each wrapper above - * stays declarative. - */ -async function cfRequest( - url: string, - token: string, - fetcher: Fetcher, - options: RequestOptions = {} -): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const init: RequestInit = { - method: options.method ?? 'GET', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json' - }, - signal: AbortSignal.timeout(timeoutMs) - }; - if (options.body !== undefined) { - init.body = JSON.stringify(options.body); - } - - let response: Response; - try { - response = await fetcher(url, init); - } catch (err) { - // `AbortSignal.timeout` rejects with a DOMException whose name is - // 'TimeoutError'. Surface it as a clearly-labelled error so callers - // can distinguish a transport hang from a Cloudflare-side failure; - // a SandboxSecurityError-shaped class would be better but we keep - // the error shape consistent with the rest of this module. - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error( - `Cloudflare API request to ${url} timed out after ${timeoutMs}ms` - ); - } - throw err; - } - if (options.acceptStatuses?.includes(response.status)) { - return undefined; - } - - let envelope: CloudflareResponse; - try { - envelope = (await response.json()) as CloudflareResponse; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `Cloudflare API returned non-JSON response (status ${response.status}): ${message}` - ); - } - - if (!response.ok || envelope.success === false) { - const errs = envelope.errors ?? []; - const summary = errs.length - ? errs - .map((e) => `${e.code ?? '???'}: ${e.message ?? 'unknown'}`) - .join(', ') - : `HTTP ${response.status}`; - throw new Error(`Cloudflare API error: ${summary}`); - } - - return envelope.result; -} - /** * Heuristic for the "tags are an Enterprise-only feature" error class. * Empirically grounded against a non-Enterprise account: diff --git a/packages/sandbox/src/tunnels/index.ts b/packages/sandbox/src/tunnels/index.ts new file mode 100644 index 000000000..4f0a2942a --- /dev/null +++ b/packages/sandbox/src/tunnels/index.ts @@ -0,0 +1,35 @@ +/** + * Public entry point for `@cloudflare/sandbox/tunnels`. + * + * Account-wide tunnel-resource helpers. Kept off the main entry point + * (and off the `Sandbox` DO surface) so per-sandbox callers don't + * accidentally reach for sweep primitives that operate on everything + * the token can see. + * + * Typical usage from a cron-triggered Worker: + * + * ```ts + * import { sweepStale } from '@cloudflare/sandbox/tunnels'; + * + * await sweepStale( + * { token, accountId, zoneId }, + * { staleAfterMs: 24 * 60 * 60_000 } + * ); + * ``` + * + * The `createScheduledTunnelCleanupHandler` helper is the zero-boilerplate + * way to wire the sweep into a Cron Trigger. + */ + +export { + type CloudflareCredentials, + type DNSSummary, + listSandboxDNSRecords, + listSandboxTunnels, + type TunnelSummary +} from './inventory'; +export { + createScheduledTunnelCleanupHandler, + type ScheduledTunnelCleanupOptions +} from './scheduled-cleanup'; +export { type SweepOptions, type SweepResult, sweepStale } from './sweep'; diff --git a/packages/sandbox/src/tunnels/inventory.ts b/packages/sandbox/src/tunnels/inventory.ts new file mode 100644 index 000000000..c1fd8d8b4 --- /dev/null +++ b/packages/sandbox/src/tunnels/inventory.ts @@ -0,0 +1,227 @@ +/** + * Account and zone inventory helpers for tunnel cleanup. + * + * This module owns paginated Cloudflare list endpoints and normalization + * into small resource summaries. Destructive cleanup code consumes these + * summaries instead of working with raw API response shapes. + */ + +import { API_BASE, cfEnvelopeRequest, type Fetcher } from './request'; + +const PAGE_SIZE = 1000; + +/** + * Credentials bundle used by account- and zone-scoped tunnel helpers. + * Tunnel-only helpers leave `zoneId` undefined; DNS helpers require it. + */ +export interface CloudflareCredentials { + token: string; + accountId: string; + zoneId?: string; + fetcher?: Fetcher; +} + +export type TunnelStatus = + | 'healthy' + | 'down' + | 'degraded' + | 'inactive' + | 'unknown'; + +/** Summary of a tunnel created by this SDK. */ +export interface TunnelSummary { + id: string; + name: string; + status: TunnelStatus; + createdAt: Date | null; + connsActiveAt: Date | null; + connsInactiveAt: Date | null; + deletedAt: Date | null; + metadata: Record | null; +} + +/** Summary of a DNS record marked with the SDK's `sandbox-` comment. */ +export interface DNSSummary { + id: string; + name: string; + type: string; + content: string; + comment: string | null; + createdAt: Date | null; +} + +export interface TunnelInventory { + /** SDK-owned tunnels eligible for stale-tunnel evaluation. */ + sandboxTunnels: TunnelSummary[]; + /** Every non-deleted tunnel id in the account, independent of metadata. */ + liveTunnelIds: Set; +} + +interface ListTunnelsRaw { + id: string; + name: string; + status?: string; + created_at?: string; + conns_active_at?: string | null; + conns_inactive_at?: string | null; + deleted_at?: string | null; + metadata?: Record | null; +} + +interface ListDNSRaw { + id: string; + name: string; + type: string; + content: string; + comment?: string | null; + created_on?: string; +} + +async function cfFullyPaginatedRequest( + urlBuilder: (page: number, perPage: number) => string, + token: string, + fetcher: Fetcher +): Promise { + const collected: T[] = []; + let page = 1; + for (;;) { + const response = await cfEnvelopeRequest( + urlBuilder(page, PAGE_SIZE), + token, + fetcher + ); + collected.push(...(response?.result ?? [])); + const totalPages = response?.result_info?.total_pages ?? 1; + if (page >= totalPages) return collected; + page += 1; + } +} + +function parseDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isSandboxTunnel( + raw: ListTunnelsRaw, + sandboxId: string | undefined +): boolean { + const meta = raw.metadata ?? null; + if (!meta || meta.createdBy !== 'sandbox-sdk') return false; + return sandboxId === undefined || meta.sandboxId === sandboxId; +} + +function toTunnelStatus(status: string | undefined): TunnelStatus { + switch (status) { + case 'healthy': + case 'down': + case 'degraded': + case 'inactive': + return status; + default: + return 'unknown'; + } +} + +function toTunnelSummary(raw: ListTunnelsRaw): TunnelSummary { + return { + id: raw.id, + name: raw.name, + status: toTunnelStatus(raw.status), + createdAt: parseDate(raw.created_at), + connsActiveAt: parseDate(raw.conns_active_at), + connsInactiveAt: parseDate(raw.conns_inactive_at), + deletedAt: parseDate(raw.deleted_at), + metadata: raw.metadata ?? null + }; +} + +function toDNSSummary(raw: ListDNSRaw): DNSSummary { + return { + id: raw.id, + name: raw.name, + type: raw.type, + content: raw.content, + comment: raw.comment ?? null, + createdAt: parseDate(raw.created_on) + }; +} + +async function listRawLiveTunnels( + creds: CloudflareCredentials, + fetcher: Fetcher +): Promise { + const base = `${API_BASE}/accounts/${encodeURIComponent(creds.accountId)}/cfd_tunnel`; + return await cfFullyPaginatedRequest( + (page, perPage) => + `${base}?is_deleted=false&page=${page}&per_page=${perPage}`, + creds.token, + fetcher + ); +} + +/** + * List SDK-owned tunnels and all live tunnel IDs from the same account + * inventory snapshot. + */ +export async function listTunnelInventory( + creds: CloudflareCredentials, + opts: { sandboxId?: string; fetcher?: Fetcher } = {} +): Promise { + const fetcher = opts.fetcher ?? creds.fetcher ?? fetch; + const raw = await listRawLiveTunnels(creds, fetcher); + return { + sandboxTunnels: raw + .filter((t) => isSandboxTunnel(t, opts.sandboxId)) + .map(toTunnelSummary), + liveTunnelIds: new Set(raw.filter((t) => !t.deleted_at).map((t) => t.id)) + }; +} + +/** List SDK-owned Cloudflare tunnels in the account. */ +export async function listSandboxTunnels( + creds: CloudflareCredentials, + opts: { sandboxId?: string; fetcher?: Fetcher } = {} +): Promise { + return (await listTunnelInventory(creds, opts)).sandboxTunnels; +} + +/** List every non-deleted Cloudflare tunnel id in the account. */ +export async function listLiveTunnelIds( + creds: CloudflareCredentials, + opts: { fetcher?: Fetcher } = {} +): Promise> { + return (await listTunnelInventory(creds, opts)).liveTunnelIds; +} + +/** + * List CNAME records in the configured zone whose comments use the SDK + * `sandbox-` marker. Cloudflare's server-side comment filter is paired + * with a client-side exact lowercase prefix check. + */ +export async function listSandboxDNSRecords( + creds: CloudflareCredentials, + opts: { sandboxId?: string; fetcher?: Fetcher } = {} +): Promise { + if (!creds.zoneId) { + throw new Error( + 'listSandboxDNSRecords requires creds.zoneId. Pass it on CloudflareCredentials.' + ); + } + const fetcher = opts.fetcher ?? creds.fetcher ?? fetch; + const base = `${API_BASE}/zones/${encodeURIComponent(creds.zoneId)}/dns_records`; + const query = 'type=CNAME&comment.startswith=sandbox-'; + const raw = await cfFullyPaginatedRequest( + (page, perPage) => `${base}?${query}&page=${page}&per_page=${perPage}`, + creds.token, + fetcher + ); + return raw + .filter((r) => { + if (!r.comment?.startsWith('sandbox-')) return false; + if (opts.sandboxId === undefined) return true; + return r.comment === `sandbox-${opts.sandboxId}`; + }) + .map(toDNSSummary); +} diff --git a/packages/sandbox/src/tunnels/request.ts b/packages/sandbox/src/tunnels/request.ts new file mode 100644 index 000000000..b2f0d09e1 --- /dev/null +++ b/packages/sandbox/src/tunnels/request.ts @@ -0,0 +1,106 @@ +/** Shared Cloudflare API request primitives for tunnel helpers. */ + +export const API_BASE = 'https://api.cloudflare.com/client/v4'; + +/** Pagination metadata on Cloudflare list endpoint responses. */ +export interface ResultInfo { + page: number; + per_page: number; + total_pages: number; + count?: number; + total_count?: number; +} + +/** Cloudflare's standard envelope around every response. */ +export interface CloudflareResponse { + success: boolean; + result?: T; + errors?: Array<{ code?: number; message?: string }>; + result_info?: ResultInfo; +} + +export type Fetcher = typeof fetch; + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: unknown; + /** Treat these HTTP statuses as success and skip envelope parsing. */ + acceptStatuses?: number[]; + /** Per-request timeout in milliseconds. */ + timeoutMs?: number; +} + +/** Default request timeout for Cloudflare control-plane calls. */ +export const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Internal request helper. Centralises auth header, JSON encoding, + * timeout enforcement, envelope parsing, and API error formatting so + * endpoint wrappers stay declarative. + */ +export async function cfEnvelopeRequest( + url: string, + token: string, + fetcher: Fetcher, + options: RequestOptions = {} +): Promise | undefined> { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const init: RequestInit = { + method: options.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + signal: AbortSignal.timeout(timeoutMs) + }; + if (options.body !== undefined) { + init.body = JSON.stringify(options.body); + } + + let response: Response; + try { + response = await fetcher(url, init); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') { + throw new Error( + `Cloudflare API request to ${url} timed out after ${timeoutMs}ms` + ); + } + throw err; + } + if (options.acceptStatuses?.includes(response.status)) { + return undefined; + } + + let envelope: CloudflareResponse; + try { + envelope = (await response.json()) as CloudflareResponse; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Cloudflare API returned non-JSON response (status ${response.status}): ${message}` + ); + } + + if (!response.ok || envelope.success === false) { + const errs = envelope.errors ?? []; + const summary = errs.length + ? errs + .map((e) => `${e.code ?? '???'}: ${e.message ?? 'unknown'}`) + .join(', ') + : `HTTP ${response.status}`; + throw new Error(`Cloudflare API error: ${summary}`); + } + + return envelope; +} + +/** Unwrap a successful Cloudflare response envelope to `result`. */ +export async function cfRequest( + url: string, + token: string, + fetcher: Fetcher, + options: RequestOptions = {} +): Promise { + return (await cfEnvelopeRequest(url, token, fetcher, options))?.result; +} diff --git a/packages/sandbox/src/tunnels/scheduled-cleanup.ts b/packages/sandbox/src/tunnels/scheduled-cleanup.ts new file mode 100644 index 000000000..0972c3aa3 --- /dev/null +++ b/packages/sandbox/src/tunnels/scheduled-cleanup.ts @@ -0,0 +1,182 @@ +/** + * Scheduled tunnel cleanup handler factory. + * + * Produces a Workers scheduled handler that runs `sweepStale` against + * env-derived Cloudflare credentials. Workers with unrelated cron jobs + * should call the returned handler from their own `controller.cron` + * dispatch instead of asking the SDK to own that routing. + * + * ```ts + * const tunnelCleanup = createScheduledTunnelCleanupHandler({ + * staleAfterMs: 24 * 60 * 60_000 + * }); + * + * export default { + * scheduled: tunnelCleanup + * }; + * ``` + * + * Credentials are read from `env` at handler-invocation time (when + * secrets are populated). When `CLOUDFLARE_API_TOKEN` is missing the + * sweep is skipped. Account and zone IDs are inferred from the token + * when unambiguous; zone inference failures degrade to tunnel-only + * cleanup. + */ + +import { resolveAccountId, resolveZoneId } from './credentials'; +import { type SweepOptions, type SweepResult, sweepStale } from './sweep'; + +/** + * Subset of `ExecutionContext` the handler relies on. Matches the + * Workers runtime shape without depending on `@cloudflare/workers-types` + * at the SDK boundary, so the helper is portable to consumers using + * looser type setups. + */ +interface ScheduledExecutionContext { + waitUntil(promise: Promise): void; +} + +/** Loose `ScheduledController` shape matching the Workers scheduled handler fields we pass through. */ +interface ScheduledControllerLike { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} + +type ScheduledHandler = ( + controller: ScheduledControllerLike, + env: Env, + ctx: ScheduledExecutionContext +) => void | Promise; + +export interface ScheduledTunnelCleanupOptions extends Pick< + SweepOptions, + 'staleAfterMs' | 'sandboxId' | 'dryRun' +> { + /** + * Override `fetch` used for Cloudflare API calls. Tests inject a + * mock; production omits it. + */ + fetcher?: typeof fetch; + /** + * Invoked when the sweep itself rejects (transport error, malformed + * envelope, etc.). Per-resource failures already land in + * `SweepResult.errors` and don't reach here. Default: `console.error`. + */ + onError?: (err: unknown) => void | Promise; + /** + * Invoked with each completed sweep's `SweepResult`. Default: + * `console.log('tunnel sweep', JSON.stringify(result))`. Override to + * pipe into a structured logger or alerting hook. + */ + onResult?: (result: SweepResult) => void | Promise; +} + +/** + * Env shape required by the handler. The handler degrades to a no-op + * sweep when `CLOUDFLARE_API_TOKEN` is missing. Account and zone ids + * may be set explicitly or inferred from the token when unambiguous. + * Without a resolved zone ID, the sweep can delete stale tunnels but + * skips DNS-record cleanup. + */ +interface ScheduledTunnelCleanupEnv { + CLOUDFLARE_API_TOKEN?: string; + CLOUDFLARE_TUNNEL_ACCOUNT_ID?: string; + CLOUDFLARE_ACCOUNT_ID?: string; + CLOUDFLARE_ZONE_ID?: string; +} + +async function runTunnelCleanup( + env: ScheduledTunnelCleanupEnv, + opts: ScheduledTunnelCleanupOptions, + onError: (err: unknown) => void | Promise +): Promise { + const token = env.CLOUDFLARE_API_TOKEN; + if (!token) return undefined; + + const credentialEnv: Record = { + CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN, + CLOUDFLARE_TUNNEL_ACCOUNT_ID: env.CLOUDFLARE_TUNNEL_ACCOUNT_ID, + CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_ZONE_ID: env.CLOUDFLARE_ZONE_ID + }; + + let accountId: string; + try { + accountId = await resolveAccountId(credentialEnv, { + overrideKey: 'CLOUDFLARE_TUNNEL_ACCOUNT_ID', + fetcher: opts.fetcher + }); + } catch (err) { + await onError(err); + return undefined; + } + + let zoneId: string | undefined; + try { + zoneId = await resolveZoneId(credentialEnv, { + token, + accountId, + fetcher: opts.fetcher + }); + } catch (err) { + await onError(err); + } + + return await sweepStale( + { + token, + accountId, + zoneId, + fetcher: opts.fetcher + }, + { + staleAfterMs: opts.staleAfterMs, + sandboxId: opts.sandboxId, + dryRun: opts.dryRun + } + ); +} + +function readCleanupEnv(env: object): ScheduledTunnelCleanupEnv { + return env as ScheduledTunnelCleanupEnv; +} + +export function createScheduledTunnelCleanupHandler< + Env extends object = object +>(opts: ScheduledTunnelCleanupOptions): ScheduledHandler { + const onError = + opts.onError ?? + ((err: unknown) => { + console.error('tunnel sweep failed', err); + }); + const onResult = + opts.onResult ?? + ((result: SweepResult) => { + console.log('tunnel sweep', JSON.stringify(result)); + }); + const reportError = async (err: unknown): Promise => { + try { + await onError(err); + } catch (handlerErr) { + console.error('tunnel sweep error handler failed', handlerErr); + } + }; + const reportResult = async (result: SweepResult): Promise => { + try { + await onResult(result); + } catch (err) { + await reportError(err); + } + }; + + return async (_controller, env, ctx) => { + const cleanupEnv = readCleanupEnv(env); + if (!cleanupEnv.CLOUDFLARE_API_TOKEN) return; + ctx.waitUntil( + runTunnelCleanup(cleanupEnv, opts, reportError).then(async (result) => { + if (result) await reportResult(result); + }, reportError) + ); + }; +} diff --git a/packages/sandbox/src/tunnels/sweep-plan.ts b/packages/sandbox/src/tunnels/sweep-plan.ts new file mode 100644 index 000000000..e4801d617 --- /dev/null +++ b/packages/sandbox/src/tunnels/sweep-plan.ts @@ -0,0 +1,139 @@ +/** Pure deletion planning for tunnel cleanup sweeps. */ + +import type { DNSSummary, TunnelSummary } from './inventory'; + +export interface SweepDeletionTarget { + id: string; + name: string; +} + +export interface SweepPlanningError { + resource: 'tunnel' | 'dns'; + id: string; + message: string; +} + +export interface TunnelDeletePlan { + scanned: number; + toDelete: SweepDeletionTarget[]; + errors: SweepPlanningError[]; +} + +export interface DNSDeletePlan { + scanned: number; + toDelete: SweepDeletionTarget[]; +} + +export interface SweepTimingOptions { + staleAfterMs: number; + now?: Date; +} + +interface ResolvedTiming { + now: Date; + thresholdMs: number; +} + +export function resolveSweepTiming(opts: SweepTimingOptions): ResolvedTiming { + validateStaleAfterMs(opts.staleAfterMs); + const now = opts.now ?? new Date(); + return { + now, + thresholdMs: now.getTime() - opts.staleAfterMs + }; +} + +export function validateStaleAfterMs(staleAfterMs: number): void { + if (!Number.isFinite(staleAfterMs) || staleAfterMs <= 0) { + throw new Error('staleAfterMs must be a finite number greater than 0'); + } +} + +export function planTunnelDeletes( + tunnels: TunnelSummary[], + thresholdMs: number +): TunnelDeletePlan { + const errors: SweepPlanningError[] = []; + const candidates: TunnelSummary[] = []; + + for (const tunnel of tunnels) { + const meta = tunnel.metadata ?? {}; + if (typeof meta.sandboxId !== 'string' || meta.sandboxId.length === 0) { + errors.push({ + resource: 'tunnel', + id: tunnel.id, + message: 'missing-identifying-metadata' + }); + continue; + } + if (tunnel.status === 'unknown') { + errors.push({ + resource: 'tunnel', + id: tunnel.id, + message: 'unknown-status' + }); + continue; + } + candidates.push(tunnel); + } + + return { + scanned: candidates.length, + toDelete: candidates + .filter((tunnel) => isTunnelStale(tunnel, thresholdMs)) + .map((tunnel) => ({ id: tunnel.id, name: tunnel.name })), + errors + }; +} + +export function planDNSDeletes( + records: DNSSummary[], + liveTunnelIds: Set, + thresholdMs: number +): DNSDeletePlan { + return { + scanned: records.length, + toDelete: records + .filter((record) => isOldOrphanCNAME(record, liveTunnelIds, thresholdMs)) + .map((record) => ({ id: record.id, name: record.name })) + }; +} + +function isTunnelStale(tunnel: TunnelSummary, thresholdMs: number): boolean { + if (tunnel.deletedAt) return false; + if (tunnel.status === 'healthy') return false; + + const mostRecent = mostRecentTimestamp([ + tunnel.connsActiveAt, + tunnel.connsInactiveAt, + tunnel.createdAt + ]); + if (mostRecent === null) return false; + return mostRecent < thresholdMs; +} + +function mostRecentTimestamp(dates: Array): number | null { + const timestamps = dates + .map((date) => date?.getTime()) + .filter( + (value): value is number => + typeof value === 'number' && Number.isFinite(value) + ); + if (timestamps.length === 0) return null; + return Math.max(...timestamps); +} + +function isOldOrphanCNAME( + record: DNSSummary, + liveTunnelIds: Set, + thresholdMs: number +): boolean { + if (record.type !== 'CNAME') return false; + const createdAt = record.createdAt?.getTime(); + if (createdAt === undefined || !Number.isFinite(createdAt)) return false; + if (createdAt >= thresholdMs) return false; + + const match = /^([^.]+)\.cfargotunnel\.com$/.exec(record.content); + if (!match) return false; + return !liveTunnelIds.has(match[1]); +} diff --git a/packages/sandbox/src/tunnels/sweep.ts b/packages/sandbox/src/tunnels/sweep.ts new file mode 100644 index 000000000..b92023978 --- /dev/null +++ b/packages/sandbox/src/tunnels/sweep.ts @@ -0,0 +1,160 @@ +/** + * Tunnel reconciler / stale-resource sweeper. + * + * Walks account and zone inventory, plans conservative deletions, then + * executes each delete best-effort. Intended for Worker Cron Triggers. + */ + +import { deleteDNSRecord, deleteTunnel } from './cloudflare-api'; +import { + type CloudflareCredentials, + listSandboxDNSRecords, + listTunnelInventory +} from './inventory'; +import { + planDNSDeletes, + planTunnelDeletes, + resolveSweepTiming, + type SweepDeletionTarget, + type SweepPlanningError +} from './sweep-plan'; + +/** + * Outcome of a single sweep run. Shape is identical between dry-run and + * destructive modes so operators can log and alert on the same fields. + */ +export interface SweepResult { + /** SDK tunnel candidates evaluated for stale deletion. */ + tunnelsScanned: number; + tunnelsDeleted: SweepDeletionTarget[]; + /** SDK-marked DNS records evaluated for orphan deletion. */ + dnsScanned: number; + dnsDeleted: SweepDeletionTarget[]; + errors: SweepPlanningError[]; +} + +export interface SweepOptions { + /** Threshold below which non-healthy resources are considered abandoned. */ + staleAfterMs: number; + /** Restrict the sweep to a single sandbox. */ + sandboxId?: string; + /** Compute the staleness threshold against this instant. */ + now?: Date; + /** Report planned deletes without issuing Cloudflare DELETE calls. */ + dryRun?: boolean; +} + +/** + * Sweep stale SDK tunnels and orphan SDK CNAME records. + * + * DNS cleanup uses raw live tunnel IDs from the account inventory, not + * just SDK-tagged tunnels. This makes DNS deletion conservative: a CNAME + * is deleted only when its target tunnel is absent from the account's + * live tunnel list and the record itself is older than the stale window. + */ +export async function sweepStale( + creds: CloudflareCredentials, + opts: SweepOptions +): Promise { + const { thresholdMs } = resolveSweepTiming(opts); + const errors: SweepPlanningError[] = []; + + const tunnelInventory = await listTunnelInventory(creds, { + sandboxId: opts.sandboxId, + fetcher: creds.fetcher + }); + const tunnelPlan = planTunnelDeletes( + tunnelInventory.sandboxTunnels, + thresholdMs + ); + errors.push(...tunnelPlan.errors); + + const tunnelsDeleted = opts.dryRun + ? tunnelPlan.toDelete + : await deleteTunnels(creds, tunnelPlan.toDelete, errors); + + const dnsDeleted: SweepDeletionTarget[] = []; + let dnsScanned = 0; + if (creds.zoneId) { + const dnsRecords = await listSandboxDNSRecords(creds, { + sandboxId: opts.sandboxId, + fetcher: creds.fetcher + }); + const liveTunnelIds = new Set(tunnelInventory.liveTunnelIds); + for (const deleted of tunnelsDeleted) { + liveTunnelIds.delete(deleted.id); + } + const dnsPlan = planDNSDeletes(dnsRecords, liveTunnelIds, thresholdMs); + dnsScanned = dnsPlan.scanned; + dnsDeleted.push( + ...(opts.dryRun + ? dnsPlan.toDelete + : await deleteDNSRecords(creds, dnsPlan.toDelete, errors)) + ); + } + + return { + tunnelsScanned: tunnelPlan.scanned, + tunnelsDeleted, + dnsScanned, + dnsDeleted, + errors + }; +} + +async function deleteTunnels( + creds: CloudflareCredentials, + targets: SweepDeletionTarget[], + errors: SweepPlanningError[] +): Promise { + const deleted: SweepDeletionTarget[] = []; + for (const target of targets) { + try { + await deleteTunnel({ + token: creds.token, + accountId: creds.accountId, + tunnelId: target.id, + fetcher: creds.fetcher + }); + deleted.push(target); + } catch (err) { + errors.push({ + resource: 'tunnel', + id: target.id, + message: errorMessage(err) + }); + } + } + return deleted; +} + +async function deleteDNSRecords( + creds: CloudflareCredentials, + targets: SweepDeletionTarget[], + errors: SweepPlanningError[] +): Promise { + if (!creds.zoneId) return []; + const deleted: SweepDeletionTarget[] = []; + for (const target of targets) { + try { + await deleteDNSRecord({ + token: creds.token, + zoneId: creds.zoneId, + recordId: target.id, + fetcher: creds.fetcher + }); + deleted.push(target); + } catch (err) { + errors.push({ + resource: 'dns', + id: target.id, + message: errorMessage(err) + }); + } + } + return deleted; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/sandbox/tests/tunnels/cloudflare-api.test.ts b/packages/sandbox/tests/tunnels/cloudflare-api.test.ts index 960ccae57..1fa3c8d7a 100644 --- a/packages/sandbox/tests/tunnels/cloudflare-api.test.ts +++ b/packages/sandbox/tests/tunnels/cloudflare-api.test.ts @@ -477,7 +477,7 @@ describe('cloudflare-api > upsertCNAME', () => { const result = await upsertCNAME({ ...baseArgs, fetcher }); expect(result.recordId).toBe('dns-id'); expect(result.reused).toBe(true); - // Only the list call \u2014 no POST/PUT was made. + // Only the list call — no POST/PUT was made. expect(fetcher).toHaveBeenCalledTimes(1); }); diff --git a/packages/sandbox/tests/tunnels/inventory.test.ts b/packages/sandbox/tests/tunnels/inventory.test.ts new file mode 100644 index 000000000..f0bb070c9 --- /dev/null +++ b/packages/sandbox/tests/tunnels/inventory.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + listLiveTunnelIds, + listSandboxDNSRecords, + listSandboxTunnels +} from '../../src/tunnels/inventory'; + +interface RawTunnel { + id: string; + name: string; + status?: string; + created_at?: string; + conns_active_at?: string | null; + conns_inactive_at?: string | null; + deleted_at?: string | null; + metadata?: Record | null; +} + +interface RawDNSRecord { + id: string; + name: string; + type: string; + content: string; + comment?: string | null; + created_on?: string; +} + +function jsonPage( + body: unknown[], + page: number, + totalPages: number, + perPage = 1000 +): Response { + return new Response( + JSON.stringify({ + success: true, + result: body, + result_info: { + page, + per_page: perPage, + total_pages: totalPages, + count: body.length, + total_count: body.length + } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + +function rawTunnel(overrides: Partial = {}): RawTunnel { + return { + id: 'tun-id', + name: 'sandbox-sb-api', + status: 'healthy', + created_at: '2026-05-01T00:00:00Z', + conns_active_at: null, + conns_inactive_at: null, + deleted_at: null, + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb' }, + ...overrides + }; +} + +function rawDNSRecord(overrides: Partial = {}): RawDNSRecord { + return { + id: 'rec-id', + name: 'api.example.com', + type: 'CNAME', + content: 'tun-id.cfargotunnel.com', + comment: 'sandbox-sb', + created_on: '2026-05-01T00:00:00Z', + ...overrides + }; +} + +describe('inventory > listSandboxTunnels', () => { + it('GETs /accounts/:id/cfd_tunnel with live tunnel pagination params', async () => { + const fetcher = vi.fn(async () => jsonPage([], 1, 1)); + await listSandboxTunnels({ token: 'tok', accountId: 'acct' }, { fetcher }); + + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0]; + expect(String(url)).toContain( + 'https://api.cloudflare.com/client/v4/accounts/acct/cfd_tunnel' + ); + expect(String(url)).toContain('is_deleted=false'); + expect(String(url)).toContain('per_page=1000'); + expect(init?.method ?? 'GET').toBe('GET'); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer tok'); + }); + + it('returns SDK-owned tunnels, applies sandbox scoping, and parses dates', async () => { + const fetcher = vi.fn(async () => + jsonPage( + [ + rawTunnel({ + id: 'sb1-tun', + name: 'sandbox-sb1-api', + status: 'down', + conns_active_at: '2026-05-10T12:00:00Z', + conns_inactive_at: '2026-05-15T08:30:00Z', + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb1' } + }), + rawTunnel({ + id: 'sb2-tun', + name: 'sandbox-sb2-api', + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb2' } + }), + rawTunnel({ + id: 'foreign', + name: 'other-tool', + metadata: { createdBy: 'another-tool' } + }), + rawTunnel({ id: 'unlabelled', name: 'mystery', metadata: null }) + ], + 1, + 1 + ) + ); + + const all = await listSandboxTunnels( + { token: 'tok', accountId: 'acct' }, + { fetcher } + ); + expect(all.map((t) => t.id)).toEqual(['sb1-tun', 'sb2-tun']); + expect(all[0]).toMatchObject({ id: 'sb1-tun', status: 'down' }); + expect(all[0].createdAt?.toISOString()).toBe('2026-05-01T00:00:00.000Z'); + expect(all[0].connsActiveAt?.toISOString()).toBe( + '2026-05-10T12:00:00.000Z' + ); + expect(all[0].connsInactiveAt?.toISOString()).toBe( + '2026-05-15T08:30:00.000Z' + ); + + const scoped = await listSandboxTunnels( + { token: 'tok', accountId: 'acct' }, + { sandboxId: 'sb1', fetcher } + ); + expect(scoped.map((t) => t.id)).toEqual(['sb1-tun']); + }); + + it('walks pagination until result_info.page reaches total_pages', async () => { + let page = 0; + const fetcher = vi.fn(async (url) => { + page += 1; + expect(String(url)).toContain(`page=${page}`); + return jsonPage([rawTunnel({ id: `tun-${page}` })], page, 3); + }); + + const result = await listSandboxTunnels( + { token: 'tok', accountId: 'acct' }, + { fetcher } + ); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(result.map((t) => t.id)).toEqual(['tun-1', 'tun-2', 'tun-3']); + }); +}); + +describe('inventory > listLiveTunnelIds', () => { + it('returns raw live tunnel ids without SDK metadata filtering', async () => { + const fetcher = vi.fn(async () => + jsonPage( + [ + rawTunnel({ id: 'sdk-tun' }), + rawTunnel({ + id: 'foreign-live-tun', + name: 'other', + metadata: { createdBy: 'another-tool' } + }) + ], + 1, + 1 + ) + ); + + const ids = await listLiveTunnelIds( + { token: 'tok', accountId: 'acct' }, + { fetcher } + ); + expect([...ids].sort()).toEqual(['foreign-live-tun', 'sdk-tun']); + }); +}); + +describe('inventory > listSandboxDNSRecords', () => { + it('GETs /zones/:id/dns_records with SDK CNAME filter params', async () => { + const fetcher = vi.fn(async () => jsonPage([], 1, 1)); + await listSandboxDNSRecords( + { token: 'tok', accountId: 'acct', zoneId: 'zone' }, + { fetcher } + ); + + const [url] = fetcher.mock.calls[0]; + expect(String(url)).toContain( + 'https://api.cloudflare.com/client/v4/zones/zone/dns_records' + ); + expect(String(url)).toContain('type=CNAME'); + expect(String(url)).toContain('comment.startswith=sandbox-'); + expect(String(url)).toContain('per_page=1000'); + }); + + it('throws before fetching when zoneId is missing', async () => { + const fetcher = vi.fn(); + await expect( + listSandboxDNSRecords({ token: 'tok', accountId: 'acct' }, { fetcher }) + ).rejects.toThrow(/zoneId/i); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('requires the exact SDK marker, applies sandbox scoping, and parses dates', async () => { + const fetcher = vi.fn(async () => + jsonPage( + [ + rawDNSRecord({ + id: 'rec-sb1', + name: 'a.example.com', + comment: 'sandbox-sb1' + }), + rawDNSRecord({ + id: 'rec-sb2', + name: 'b.example.com', + comment: 'sandbox-sb2' + }), + rawDNSRecord({ id: 'case-mismatch', comment: 'Sandbox-sb1' }) + ], + 1, + 1 + ) + ); + + const all = await listSandboxDNSRecords( + { token: 'tok', accountId: 'acct', zoneId: 'zone' }, + { fetcher } + ); + expect(all.map((r) => r.id)).toEqual(['rec-sb1', 'rec-sb2']); + expect(all[0].createdAt?.toISOString()).toBe('2026-05-01T00:00:00.000Z'); + + const scoped = await listSandboxDNSRecords( + { token: 'tok', accountId: 'acct', zoneId: 'zone' }, + { sandboxId: 'sb1', fetcher } + ); + expect(scoped.map((r) => r.id)).toEqual(['rec-sb1']); + }); + + it('walks pagination', async () => { + let page = 0; + const fetcher = vi.fn(async () => { + page += 1; + return jsonPage([rawDNSRecord({ id: `rec-${page}` })], page, 2); + }); + + const result = await listSandboxDNSRecords( + { token: 'tok', accountId: 'acct', zoneId: 'zone' }, + { fetcher } + ); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(result.map((r) => r.id)).toEqual(['rec-1', 'rec-2']); + }); +}); diff --git a/packages/sandbox/tests/tunnels/scheduled-cleanup.test.ts b/packages/sandbox/tests/tunnels/scheduled-cleanup.test.ts new file mode 100644 index 000000000..62cf1ea6b --- /dev/null +++ b/packages/sandbox/tests/tunnels/scheduled-cleanup.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScheduledTunnelCleanupHandler } from '../../src/tunnels/scheduled-cleanup'; + +const STALE_AFTER_MS = 24 * 60 * 60_000; + +interface ScheduledControllerFixture { + scheduledTime: number; + cron: string; + noRetry(): void; +} + +function controller(cron = '0 3 * * *'): ScheduledControllerFixture { + return { scheduledTime: 0, cron, noRetry: () => {} }; +} + +function jsonOK(body: unknown): Response { + return new Response(JSON.stringify({ success: true, result: body }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +function jsonPage(body: unknown[]): Response { + return new Response( + JSON.stringify({ + success: true, + result: body, + result_info: { page: 1, per_page: 1000, total_pages: 1 } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + +function buildCtx(): { + ctx: { + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + }; + waited: Promise[]; +} { + const waited: Promise[] = []; + return { + ctx: { + waitUntil: (promise) => { + waited.push(promise); + }, + passThroughOnException: () => {} + }, + waited + }; +} + +function routedFetcher( + routes: Record Response)> +): typeof fetch { + const ordered = Object.entries(routes).sort( + ([a], [b]) => b.length - a.length + ); + return vi.fn(async (input) => { + const url = new URL(String(input)); + for (const [match, response] of ordered) { + if (url.toString().includes(match)) { + return typeof response === 'function' + ? response(url) + : response.clone(); + } + } + return new Response(`No mock route for ${url.toString()}`, { status: 599 }); + }); +} + +describe('createScheduledTunnelCleanupHandler', () => { + it('runs cleanup in waitUntil using env credentials', async () => { + const fetcher = vi.fn(async () => jsonPage([])); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { + CLOUDFLARE_API_TOKEN: 'tok', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_ZONE_ID: 'zone' + }, + ctx + ); + + expect(waited).toHaveLength(1); + await Promise.all(waited); + const urls = fetcher.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/cfd_tunnel'))).toBe(true); + expect(urls.some((u) => u.includes('/dns_records'))).toBe(true); + }); + + it('infers account and zone IDs from the token when env IDs are omitted', async () => { + const fetcher = routedFetcher({ + '/user/tokens/verify': new Response( + JSON.stringify({ + success: true, + result: { id: 'token-id', status: 'active' }, + result_info: { account: { id: 'acct' } } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + '/zones?': jsonOK([{ id: 'zone', name: 'example.com' }]), + '/cfd_tunnel': jsonPage([]), + '/dns_records': jsonPage([]) + }); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler(controller(), { CLOUDFLARE_API_TOKEN: 'tok' }, ctx); + + expect(waited).toHaveLength(1); + await Promise.all(waited); + const urls = vi.mocked(fetcher).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/user/tokens/verify'))).toBe(true); + expect(urls.some((u) => u.includes('/zones?'))).toBe(true); + expect(urls.some((u) => u.includes('/cfd_tunnel'))).toBe(true); + expect(urls.some((u) => u.includes('/dns_records'))).toBe(true); + }); + + it('falls back to tunnel-only cleanup when zone inference fails', async () => { + const onError = vi.fn(); + const fetcher = routedFetcher({ + '/user/tokens/verify': new Response( + JSON.stringify({ + success: true, + result: { id: 'token-id', status: 'active' }, + result_info: { account: { id: 'acct' } } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + '/zones?': jsonOK([ + { id: 'zone-a', name: 'a.example' }, + { id: 'zone-b', name: 'b.example' } + ]), + '/cfd_tunnel': jsonPage([]) + }); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onError, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler(controller(), { CLOUDFLARE_API_TOKEN: 'tok' }, ctx); + + expect(waited).toHaveLength(1); + await Promise.all(waited); + const urls = vi.mocked(fetcher).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/cfd_tunnel'))).toBe(true); + expect(urls.some((u) => u.includes('/dns_records'))).toBe(false); + expect(onError).toHaveBeenCalledTimes(1); + expect(String(onError.mock.calls[0][0])).toMatch(/multiple zones/i); + }); + + it('is a no-op when CLOUDFLARE_API_TOKEN is missing', async () => { + const fetcher = vi.fn(); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { CLOUDFLARE_ACCOUNT_ID: 'acct', CLOUDFLARE_ZONE_ID: 'zone' }, + ctx + ); + + expect(waited).toHaveLength(0); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('reports account inference failures without rejecting cron work', async () => { + const onError = vi.fn(); + const fetcher = vi.fn( + async () => + new Response( + JSON.stringify({ + success: true, + result: { id: 'token-id', status: 'active' }, + result_info: {} + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onError, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { CLOUDFLARE_API_TOKEN: 'tok', CLOUDFLARE_ZONE_ID: 'zone' }, + ctx + ); + + expect(waited).toHaveLength(1); + await expect(Promise.all(waited)).resolves.toBeDefined(); + expect(onError).toHaveBeenCalledTimes(1); + expect(String(onError.mock.calls[0][0])).toMatch(/single account/i); + }); + + it('swallows sweep failures so cron does not retry on transient errors', async () => { + const onError = vi.fn(); + const fetcher = vi.fn( + async () => new Response('boom', { status: 500 }) + ); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onError, + onResult: vi.fn() + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { + CLOUDFLARE_API_TOKEN: 'tok', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_ZONE_ID: 'zone' + }, + ctx + ); + + await expect(Promise.all(waited)).resolves.toBeDefined(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('routes onResult failures to onError without rejecting waitUntil', async () => { + const fetcher = vi.fn(async () => jsonPage([])); + const resultError = new Error('metrics sink unavailable'); + const onResult = vi.fn(async () => { + throw resultError; + }); + const onError = vi.fn(); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + fetcher, + onResult, + onError + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { + CLOUDFLARE_API_TOKEN: 'tok', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_ZONE_ID: 'zone' + }, + ctx + ); + + await expect(Promise.all(waited)).resolves.toBeDefined(); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(resultError); + }); + + it('passes sandboxId through to the sweep', async () => { + const deletedTunnels: string[] = []; + const staleTimestamp = new Date( + Date.now() - 2 * STALE_AFTER_MS + ).toISOString(); + const fetcher = vi.fn(async (input, init) => { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input)); + if (method === 'GET' && url.pathname.endsWith('/cfd_tunnel')) { + return jsonPage([ + { + id: 'sb1-tun', + name: 'sandbox-sb1-api', + status: 'down', + created_at: staleTimestamp, + conns_active_at: null, + conns_inactive_at: staleTimestamp, + deleted_at: null, + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb1' } + }, + { + id: 'sb2-tun', + name: 'sandbox-sb2-api', + status: 'down', + created_at: staleTimestamp, + conns_active_at: null, + conns_inactive_at: staleTimestamp, + deleted_at: null, + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb2' } + } + ]); + } + if (method === 'GET' && url.pathname.endsWith('/dns_records')) { + return jsonPage([]); + } + if (method === 'DELETE' && url.pathname.includes('/cfd_tunnel/')) { + deletedTunnels.push(url.pathname.split('/cfd_tunnel/')[1]); + return jsonOK({ id: 'ok' }); + } + throw new Error(`unexpected request: ${method} ${String(input)}`); + }); + const onResult = vi.fn(); + const handler = createScheduledTunnelCleanupHandler({ + staleAfterMs: STALE_AFTER_MS, + sandboxId: 'sb1', + fetcher, + onResult + }); + const { ctx, waited } = buildCtx(); + + await handler( + controller(), + { + CLOUDFLARE_API_TOKEN: 'tok', + CLOUDFLARE_ACCOUNT_ID: 'acct', + CLOUDFLARE_ZONE_ID: 'zone' + }, + ctx + ); + await Promise.all(waited); + + expect(deletedTunnels).toEqual(['sb1-tun']); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + tunnelsDeleted: [{ id: 'sb1-tun', name: 'sandbox-sb1-api' }] + }) + ); + }); +}); diff --git a/packages/sandbox/tests/tunnels/sweep-plan.test.ts b/packages/sandbox/tests/tunnels/sweep-plan.test.ts new file mode 100644 index 000000000..67f120d5d --- /dev/null +++ b/packages/sandbox/tests/tunnels/sweep-plan.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import type { DNSSummary, TunnelSummary } from '../../src/tunnels/inventory'; +import { + planDNSDeletes, + planTunnelDeletes, + resolveSweepTiming +} from '../../src/tunnels/sweep-plan'; + +const NOW = new Date('2026-05-26T12:00:00Z'); +const ONE_DAY_MS = 24 * 60 * 60_000; +const THRESHOLD_MS = NOW.getTime() - ONE_DAY_MS; + +function tunnel(overrides: Partial = {}): TunnelSummary { + return { + id: 'tun-id', + name: 'sandbox-sb-api', + status: 'down', + createdAt: new Date('2026-05-01T00:00:00Z'), + connsActiveAt: null, + connsInactiveAt: null, + deletedAt: null, + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb' }, + ...overrides + }; +} + +function dns(overrides: Partial = {}): DNSSummary { + return { + id: 'rec-id', + name: 'api.example.com', + type: 'CNAME', + content: 'missing-tun.cfargotunnel.com', + comment: 'sandbox-sb', + createdAt: new Date('2026-05-01T00:00:00Z'), + ...overrides + }; +} + +describe('resolveSweepTiming', () => { + it('rejects non-positive stale windows', () => { + expect(() => resolveSweepTiming({ staleAfterMs: 0, now: NOW })).toThrow( + /staleAfterMs/i + ); + }); + + it('computes the threshold from the provided clock', () => { + const timing = resolveSweepTiming({ staleAfterMs: ONE_DAY_MS, now: NOW }); + expect(timing.thresholdMs).toBe(THRESHOLD_MS); + }); +}); + +describe('planTunnelDeletes', () => { + it.each([ + [ + 'stale non-healthy tunnel', + tunnel({ + id: 'old', + connsInactiveAt: new Date(NOW.getTime() - 2 * ONE_DAY_MS) + }), + ['old'] + ], + [ + 'never-connected old tunnel', + tunnel({ + id: 'ghost', + status: 'inactive', + createdAt: new Date(NOW.getTime() - 3 * ONE_DAY_MS), + connsActiveAt: null, + connsInactiveAt: null + }), + ['ghost'] + ], + [ + 'healthy tunnel with stale inactive timestamp', + tunnel({ + id: 'healthy', + status: 'healthy', + connsInactiveAt: new Date(NOW.getTime() - 7 * ONE_DAY_MS) + }), + [] + ], + [ + 'recently inactive tunnel', + tunnel({ + id: 'recent', + connsInactiveAt: new Date(NOW.getTime() - 60_000) + }), + [] + ], + [ + 'recently reconnected tunnel', + tunnel({ + id: 'reconnected', + connsInactiveAt: new Date(NOW.getTime() - 5 * ONE_DAY_MS), + connsActiveAt: new Date(NOW.getTime() - 60_000) + }), + [] + ], + [ + 'soft-deleted tunnel', + tunnel({ + id: 'soft-deleted', + deletedAt: new Date('2026-05-01T00:00:00Z') + }), + [] + ], + [ + 'tunnel without a valid timestamp', + tunnel({ + id: 'unknown-age', + createdAt: null, + connsActiveAt: null, + connsInactiveAt: null + }), + [] + ] + ])('handles %s', (_label, input, expectedIds) => { + const plan = planTunnelDeletes([input], THRESHOLD_MS); + expect(plan.toDelete.map((t) => t.id)).toEqual(expectedIds); + expect(plan.errors).toEqual([]); + }); + + it('does not scan or delete tunnels without identifying metadata', () => { + const plan = planTunnelDeletes( + [tunnel({ id: 'ambiguous', metadata: { createdBy: 'sandbox-sdk' } })], + THRESHOLD_MS + ); + expect(plan.toDelete).toEqual([]); + expect(plan.scanned).toBe(0); + expect(plan.errors).toEqual([ + { + resource: 'tunnel', + id: 'ambiguous', + message: 'missing-identifying-metadata' + } + ]); + }); + + it('does not scan or delete unknown tunnel status values', () => { + const plan = planTunnelDeletes( + [ + tunnel({ + id: 'future-status', + status: 'unknown', + createdAt: new Date(NOW.getTime() - 2 * ONE_DAY_MS) + }) + ], + THRESHOLD_MS + ); + expect(plan.toDelete).toEqual([]); + expect(plan.scanned).toBe(0); + expect(plan.errors).toEqual([ + { + resource: 'tunnel', + id: 'future-status', + message: 'unknown-status' + } + ]); + }); +}); + +describe('planDNSDeletes', () => { + it('plans deletion for old CNAMEs whose target tunnel is absent', () => { + const plan = planDNSDeletes([dns()], new Set(['other-tun']), THRESHOLD_MS); + expect(plan.scanned).toBe(1); + expect(plan.toDelete).toEqual([{ id: 'rec-id', name: 'api.example.com' }]); + }); + + it('keeps DNS when the raw live tunnel id exists', () => { + const plan = planDNSDeletes( + [dns({ content: 'live-tun.cfargotunnel.com' })], + new Set(['live-tun']), + THRESHOLD_MS + ); + expect(plan.toDelete).toEqual([]); + }); + + it('keeps DNS until its own age crosses the stale threshold', () => { + const plan = planDNSDeletes( + [dns({ createdAt: new Date(NOW.getTime() - 60_000) })], + new Set(), + THRESHOLD_MS + ); + expect(plan.toDelete).toEqual([]); + }); + + it('keeps DNS when the creation timestamp is unavailable', () => { + const plan = planDNSDeletes( + [dns({ id: 'unknown-age', createdAt: null })], + new Set(), + THRESHOLD_MS + ); + expect(plan.scanned).toBe(1); + expect(plan.toDelete).toEqual([]); + }); +}); diff --git a/packages/sandbox/tests/tunnels/sweep.test.ts b/packages/sandbox/tests/tunnels/sweep.test.ts new file mode 100644 index 000000000..5bd3969be --- /dev/null +++ b/packages/sandbox/tests/tunnels/sweep.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sweepStale } from '../../src/tunnels/sweep'; + +interface TunnelFixture { + id: string; + name: string; + status?: string; + created_at?: string; + conns_active_at?: string | null; + conns_inactive_at?: string | null; + deleted_at?: string | null; + metadata?: Record | null; +} + +interface DNSFixture { + id: string; + name: string; + type?: string; + content: string; + comment?: string | null; + created_on?: string; +} + +function jsonPage(body: unknown[]): Response { + return new Response( + JSON.stringify({ + success: true, + result: body, + result_info: { + page: 1, + per_page: 1000, + total_pages: 1, + count: body.length, + total_count: body.length + } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + +function jsonOK(body: unknown): Response { + return new Response(JSON.stringify({ success: true, result: body }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +function buildFetcher(opts: { + tunnels: TunnelFixture[]; + dns: DNSFixture[]; + failOnDelete?: (url: string) => Response | null; +}): { + fetcher: typeof fetch; + deletedTunnels: string[]; + deletedDNS: string[]; +} { + const deletedTunnels: string[] = []; + const deletedDNS: string[] = []; + const fetcher = vi.fn(async (input, init) => { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input)); + + if (method === 'GET' && url.pathname.endsWith('/cfd_tunnel')) { + return jsonPage(opts.tunnels); + } + if (method === 'GET' && url.pathname.endsWith('/dns_records')) { + return jsonPage(opts.dns); + } + if (method === 'DELETE' && url.pathname.includes('/cfd_tunnel/')) { + const failure = opts.failOnDelete?.(String(input)); + if (failure) return failure; + deletedTunnels.push(url.pathname.split('/cfd_tunnel/')[1]); + return jsonOK({ id: 'ok' }); + } + if (method === 'DELETE' && url.pathname.includes('/dns_records/')) { + const failure = opts.failOnDelete?.(String(input)); + if (failure) return failure; + deletedDNS.push(url.pathname.split('/dns_records/')[1]); + return jsonOK({ id: 'ok' }); + } + + throw new Error(`unexpected request: ${method} ${String(input)}`); + }); + return { fetcher, deletedTunnels, deletedDNS }; +} + +const NOW = new Date('2026-05-26T12:00:00Z'); +const ONE_DAY_MS = 24 * 60 * 60_000; + +function tunnel(overrides: Partial): TunnelFixture { + return { + id: 'tun-id', + name: 'sandbox-sb-api', + status: 'down', + created_at: '2026-05-01T00:00:00Z', + conns_active_at: null, + conns_inactive_at: null, + deleted_at: null, + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb' }, + ...overrides + }; +} + +function dns(overrides: Partial): DNSFixture { + return { + id: 'rec-id', + name: 'api.example.com', + type: 'CNAME', + content: 'missing-tun.cfargotunnel.com', + comment: 'sandbox-sb', + created_on: '2026-05-01T00:00:00Z', + ...overrides + }; +} + +describe('sweepStale', () => { + it('deletes a stale tunnel and reports the completed deletion', async () => { + const { fetcher, deletedTunnels } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'stale-tun', + conns_inactive_at: new Date( + NOW.getTime() - 2 * ONE_DAY_MS + ).toISOString() + }) + ], + dns: [] + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW } + ); + + expect(deletedTunnels).toEqual(['stale-tun']); + expect(result.tunnelsDeleted).toEqual([ + { id: 'stale-tun', name: 'sandbox-sb-api' } + ]); + expect(result.tunnelsScanned).toBe(1); + expect(result.errors).toEqual([]); + }); + + it('refuses to delete an SDK tunnel without a sandboxId', async () => { + const { fetcher, deletedTunnels } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'no-sb-id', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString(), + metadata: { createdBy: 'sandbox-sdk' } + }) + ], + dns: [] + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW } + ); + + expect(deletedTunnels).toEqual([]); + expect(result.errors).toEqual([ + { + resource: 'tunnel', + id: 'no-sb-id', + message: 'missing-identifying-metadata' + } + ]); + }); + + it('deletes a stale tunnel and its paired CNAME in the same sweep', async () => { + const { fetcher, deletedTunnels, deletedDNS } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'stale-tun', + conns_inactive_at: new Date( + NOW.getTime() - 2 * ONE_DAY_MS + ).toISOString() + }) + ], + dns: [ + dns({ + id: 'rec-stale', + content: 'stale-tun.cfargotunnel.com' + }) + ] + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW } + ); + + expect(deletedTunnels).toEqual(['stale-tun']); + expect(deletedDNS).toEqual(['rec-stale']); + expect(result.dnsScanned).toBe(1); + expect(result.dnsDeleted).toEqual([ + { id: 'rec-stale', name: 'api.example.com' } + ]); + }); + + it('keeps DNS when the target tunnel exists outside SDK metadata', async () => { + const { fetcher, deletedDNS } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'live-raw-tun', + status: 'healthy', + metadata: { createdBy: 'another-tool' } + }) + ], + dns: [ + dns({ + id: 'points-at-live-raw-tunnel', + content: 'live-raw-tun.cfargotunnel.com' + }) + ] + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW } + ); + + expect(deletedDNS).toEqual([]); + expect(result.dnsScanned).toBe(1); + expect(result.dnsDeleted).toEqual([]); + }); + + it('reports planned deletes in dry run without issuing DELETE requests', async () => { + const { fetcher, deletedTunnels, deletedDNS } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'would-delete', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString() + }) + ], + dns: [ + dns({ + id: 'would-delete-dns', + name: 'old.example.com', + content: 'no-such-tunnel.cfargotunnel.com' + }) + ] + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW, dryRun: true } + ); + + expect(deletedTunnels).toEqual([]); + expect(deletedDNS).toEqual([]); + expect(result.tunnelsDeleted).toEqual([ + { id: 'would-delete', name: 'sandbox-sb-api' } + ]); + expect(result.dnsDeleted).toEqual([ + { id: 'would-delete-dns', name: 'old.example.com' } + ]); + }); + + it('records per-resource delete failures without aborting the sweep', async () => { + const { fetcher, deletedTunnels } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'fail-tun', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString() + }), + tunnel({ + id: 'ok-tun', + name: 'sandbox-sb-other', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString() + }) + ], + dns: [], + failOnDelete: (url) => + url.includes('fail-tun') + ? new Response( + JSON.stringify({ + success: false, + errors: [{ code: 9999, message: 'transient' }] + }), + { status: 500, headers: { 'content-type': 'application/json' } } + ) + : null + }); + + const result = await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW } + ); + + expect(deletedTunnels).toEqual(['ok-tun']); + expect(result.tunnelsDeleted).toEqual([ + { id: 'ok-tun', name: 'sandbox-sb-other' } + ]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ + resource: 'tunnel', + id: 'fail-tun' + }); + expect(result.errors[0].message).toMatch(/9999|transient/); + }); + + it('rejects invalid staleAfterMs before issuing Cloudflare requests', async () => { + const fetcher = vi.fn(); + await expect( + sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: -1, now: NOW } + ) + ).rejects.toThrow(/staleAfterMs/i); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('scopes deletions to the requested sandboxId', async () => { + const { fetcher, deletedTunnels } = buildFetcher({ + tunnels: [ + tunnel({ + id: 'sb1-tun', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString(), + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb1' } + }), + tunnel({ + id: 'sb2-tun', + conns_inactive_at: new Date( + NOW.getTime() - 7 * ONE_DAY_MS + ).toISOString(), + metadata: { createdBy: 'sandbox-sdk', sandboxId: 'sb2' } + }) + ], + dns: [] + }); + + await sweepStale( + { token: 'tok', accountId: 'acct', zoneId: 'zone', fetcher }, + { staleAfterMs: ONE_DAY_MS, now: NOW, sandboxId: 'sb1' } + ); + + expect(deletedTunnels).toEqual(['sb1-tun']); + }); +}); diff --git a/packages/sandbox/tsdown.config.ts b/packages/sandbox/tsdown.config.ts index 39b66f148..3f1f730a3 100644 --- a/packages/sandbox/tsdown.config.ts +++ b/packages/sandbox/tsdown.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ 'src/interpreter/index.ts', 'src/openai/index.ts', 'src/opencode/index.ts', - 'src/xterm/index.ts' + 'src/xterm/index.ts', + 'src/tunnels/index.ts' ], external: ['cloudflare:workers', 'hono'], loader: {