From 1b07ddb17ca293a91bf126da44d1aa22ba69f76c Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 25 May 2026 20:50:22 -0700 Subject: [PATCH] fix(coolify): URL-token auth + test connection UI + IP allowlist for webhook ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify's webhook UI only exposes a single URL field (no headers, no signing), so the original HMAC-header design was unusable against real deployments. Replace it with a per-user token embedded in the URL path, while keeping the legacy HMAC route mounted for a 30-day grace period. Part 1 — URL-token auth - New primary route: POST /api/coolify/webhook/:user_id/:token token IS users.coolify_webhook_secret, constant-time compared. - Legacy POST /api/coolify/webhook/:user_id kept; returns Deprecation + Sunset headers and logs a warning per hit. - GET/POST /api/account/coolify-webhook-secret now returns the full URL with the token embedded. Part 2 — Test-connection UI + audit log - New table coolify_webhook_attempts (capped 100/user, app-side trim). - Every hit logged: success, auth_failed, ip_rejected, bad_payload, legacy_hmac. - GET /api/account/coolify-webhook-attempts?limit=N (JWT). - SettingsPage Coolify Webhook card renders the last 10 attempts inline with status pill + IP + reason + relative timestamp + refresh button. Part 3 — IP allowlist (defense in depth) - Optional users.coolify_webhook_allowed_ips (CSV of IPv4/IPv6/CIDR). NULL/empty = allow all (back-compat). - GET/PUT /api/account/coolify-webhook-allowed-ips (JWT, validates CIDR). - Zero-dep CIDR helper at hub/src/lib/cidr.ts (IPv4 + IPv6 + CIDR). - Rejected requests get 403 + audit row with reason 'source_ip_not_in_allowlist'. Coolify event-name fix (discovered mid-flight from PR #42 investigation) - Coolify's SendWebhookJob emits underscore event names (deployment_success, deployment_failed), not the dotted form. - Zod schema now accepts both and normalizes internally via EVENT_ALIAS. Tests - 34 new/updated tests in hub/test/{coolify-webhook,cidr}.test.ts cover URL-token success + wrong-token, no-secret enumeration safety, underscore event aliasing, IP allowlist match/mismatch/CIDR/x-forwarded-for, legacy HMAC success + Deprecation header + every failure path. - Full hub suite: 132 pass / 0 fail. - web/bun run build: green. Docs - docs/coolify-webhook-migration.md: new "2026-05-25 update" section documents URL-token auth, Coolify event-name shape, IP allowlist, legacy grace period, audit log. - CLAUDE.md: file-map entries updated to reflect new ingress shape + new endpoints + cidr helper. User action post-deploy: re-rotate via Settings → Supervisor → Coolify Webhook (the previously-pasted URL will keep working via the legacy HMAC route, but Coolify can't actually send HMAC headers, so re-rotating to get the new URL format is required for Coolify-originated webhooks to succeed). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 +- docs/coolify-webhook-migration.md | 42 +++ hub/src/api/account.ts | 84 +++++- hub/src/api/coolify-webhook.ts | 303 ++++++++++++++----- hub/src/db/dal.ts | 120 ++++++++ hub/src/db/schema.sql | 21 ++ hub/src/lib/cidr.ts | 160 ++++++++++ hub/test/cidr.test.ts | 86 ++++++ hub/test/coolify-webhook.test.ts | 445 +++++++++++++++++----------- web/src/components/SettingsPage.tsx | 211 ++++++++++--- 10 files changed, 1184 insertions(+), 293 deletions(-) create mode 100644 hub/src/lib/cidr.ts create mode 100644 hub/test/cidr.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a7e29d7..b4314f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,8 +221,9 @@ Absorbs the standalone `coolify-ai-monitor` Express service (port 3032, now reti **File map (shipped on this branch):** -- `hub/src/api/coolify-webhook.ts` — public `POST /api/coolify/webhook/:user_id`; reads raw body BEFORE JSON parse, HMAC-verifies, persists deployment metadata, stubs triage dispatch. -- `hub/src/api/account.ts` — `POST /api/account/coolify-webhook-secret/rotate` + `GET .../coolify-webhook-secret` (JWT-authed status). +- `hub/src/api/coolify-webhook.ts` — public ingress with TWO routes: primary `POST /api/coolify/webhook/:user_id/:token` (URL-path token, constant-time compare — matches Coolify's URL-only webhook UI) and legacy `POST /api/coolify/webhook/:user_id` (HMAC headers, deprecated 30-day grace, returns `Deprecation: true` + `Sunset:`). Both flow through a shared `handleAuthenticated` pipeline: optional IP allowlist (`users.coolify_webhook_allowed_ips`) → Zod validate (event-name `EVENT_ALIAS` normalizes `deployment_success`/`deployment_failed` underscore forms emitted by Coolify's `SendWebhookJob` to the dotted internal canonical) → persist run → triage on failure → audit row in `coolify_webhook_attempts` (every hit, capped 100/user). CIDR helper at `hub/src/lib/cidr.ts`. +- `hub/src/api/account.ts` — `POST /api/account/coolify-webhook-secret/rotate` + `GET .../coolify-webhook-secret` (returns full URL with token embedded), `GET .../coolify-webhook-attempts?limit=10` (audit log), `GET`/`PUT .../coolify-webhook-allowed-ips`. +- `hub/src/lib/cidr.ts` — zero-dep IPv4/IPv6 + CIDR allowlist helper. `parseAllowlist`, `ipAllowed`, `sourceIpFromHeaders` (cf-connecting-ip → x-real-ip → first x-forwarded-for hop). - `hub/src/scheduler/triage-schema.ts` — `TriageResult` Zod schema + `parseTriageOutput` (tolerates ```json fences, rejects bare prose). - `hub/src/scheduler/triage-prompt.ts` — `renderTriagePrompt` template for `task_kind: 'triage'` runs. - `hub/src/scheduler/post-run/schema.ts` — `github_issue` variant added to the discriminated union. diff --git a/docs/coolify-webhook-migration.md b/docs/coolify-webhook-migration.md index 1c56636..378e580 100644 --- a/docs/coolify-webhook-migration.md +++ b/docs/coolify-webhook-migration.md @@ -4,6 +4,48 @@ This runbook walks through migrating the Coolify deployment webhook from the leg --- +## 2026-05-25 update: URL-path token auth (replaces HMAC headers) + +Coolify's Notifications → Webhook UI exposes only a single URL field — no header configuration, no signing secret support. Phase 06's original HMAC-header design (`X-Coolify-Signature` + `X-Coolify-Timestamp`) was therefore unusable against real Coolify deployments. + +The webhook now authenticates via a per-user token embedded directly in the URL path: + +``` +POST https://app.remo-code.com/api/coolify/webhook// +``` + +`` is the same `users.coolify_webhook_secret` UUID generated by `POST /api/account/coolify-webhook-secret/rotate`. **The full URL itself is the credential — treat it as a secret.** + +### Configuring Coolify + +1. In remo-code: **Settings → Supervisor → Coolify Webhook → Generate URL**. Copy the URL. +2. In Coolify: **Project → Notifications → Webhook (POST)**. Paste the URL into the single URL field. Save. +3. Optional: send a test notification from Coolify. Within seconds the **Recent webhook attempts** list in remo-code should show a `success` row. + +### Coolify event-name shape + +Coolify's `SendWebhookJob` emits underscore event names (`deployment_success`, `deployment_failed`), not the dotted form some docs imply. The handler accepts both at the wire and normalizes internally; no user action required. + +### Optional: IP allowlist (defense in depth) + +Settings → Supervisor → Coolify Webhook → **Allowed source IPs**. Comma-separated IPv4 / IPv6 / CIDR. Leave blank to allow any source (back-compat default). + +``` +46.224.61.233, 10.0.0.0/8 +``` + +Your Coolify server's IP is likely `46.224.61.233` (titaniumlabs.us). Rejected requests are logged in the **Recent webhook attempts** list with status `ip_rejected` so misconfigurations surface immediately. + +### Legacy HMAC route (30-day grace period) + +The pre-fix HMAC route at `POST /api/coolify/webhook/:user_id` is still mounted for 30 days. It now returns `Deprecation: true` + `Sunset:` headers and logs a warning per hit. After grace, the route will be removed. Re-rotate to migrate. + +### Audit log + +Every webhook hit — success, auth-fail, ip-reject, bad-payload — writes one row to `coolify_webhook_attempts` (capped at 100 rows/user via app-side trim). Surfaced in the UI and via `GET /api/account/coolify-webhook-attempts?limit=10`. + +--- + ## 1. Why retire Phase 06 absorbed the entire `coolify-ai-monitor` feature surface (G2 deployment-failure capture, G3 log classification, G4 GitHub-issue dispatch, G5 webhook secret management, and G6 triage orchestration) directly into remo-code's scheduler + post-run action framework. With Phase 06 plans 001–008 deployed, every behavior the legacy app provided now runs natively in the hub against a single Postgres database, sharing auth, rate-limiting, and observability with the rest of the platform. There is no longer any reason to operate a second app for Coolify monitoring — retiring it frees port 3032, eliminates a Mongo dependency, and consolidates triage history into `scheduled_task_runs`. diff --git a/hub/src/api/account.ts b/hub/src/api/account.ts index a093dae..cbdee5a 100644 --- a/hub/src/api/account.ts +++ b/hub/src/api/account.ts @@ -3,6 +3,10 @@ import { authMiddleware } from '../auth/middleware.ts'; import { getUserCoolifyWebhookStatus, rotateUserCoolifyWebhookSecret, + getUserCoolifyWebhookSecret, + getUserCoolifyWebhookAllowedIps, + setUserCoolifyWebhookAllowedIps, + listCoolifyWebhookAttempts, } from '../db/dal.ts'; export const accountRouter = new Hono(); @@ -14,20 +18,31 @@ function publicBase(): string { return process.env.REMO_PUBLIC_URL || 'https://app.remo-code.com'; } -function webhookUrlFor(userId: string): string { - return `${publicBase()}/api/coolify/webhook/${userId}`; +/** + * fix/coolify-webhook-url-token: the canonical webhook URL now embeds the + * per-user secret as the final path segment. This is the URL the user pastes + * into Coolify's single-field Notifications → Webhook UI. + */ +function webhookUrlFor(userId: string, token: string | null): string { + if (!token) { + return `${publicBase()}/api/coolify/webhook/${userId}`; + } + return `${publicBase()}/api/coolify/webhook/${userId}/${token}`; } // GET /api/account/coolify-webhook-secret -// Returns whether the secret is configured + the user's webhook URL. -// NEVER returns the secret value itself. +// Returns whether the secret is configured + the FULL webhook URL (with the +// token embedded in the path). The URL itself is the credential — treat it +// as a secret. We surface it to the owning user only. accountRouter.get('/coolify-webhook-secret', async (c) => { const userId = c.get('userId') as string; try { const status = await getUserCoolifyWebhookStatus(userId); + const secret = status.configured ? await getUserCoolifyWebhookSecret(userId) : null; return c.json({ configured: status.configured, - webhook_url: webhookUrlFor(userId), + webhook_url: webhookUrlFor(userId, secret), + auth_mode: 'url_token', }); } catch (err: any) { console.error('[account] coolify-webhook-secret GET failed:', err?.code, err?.message); @@ -36,19 +51,70 @@ accountRouter.get('/coolify-webhook-secret', async (c) => { }); // POST /api/account/coolify-webhook-secret/rotate -// Generates a fresh UUID secret and returns it ONCE. +// Generates a fresh UUID secret and returns the new URL (token embedded). accountRouter.post('/coolify-webhook-secret/rotate', async (c) => { const userId = c.get('userId') as string; try { const secret = await rotateUserCoolifyWebhookSecret(userId); return c.json({ secret, - webhook_url: webhookUrlFor(userId), - header_format: 'X-Coolify-Signature: sha256=', - timestamp_header: 'X-Coolify-Timestamp', + webhook_url: webhookUrlFor(userId, secret), + auth_mode: 'url_token', + note: 'Paste webhook_url into Coolify Notifications → Webhook. The URL itself is the credential — treat it as a secret.', }); } catch (err: any) { console.error('[account] coolify-webhook-secret rotate failed:', err?.code, err?.message); return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); } }); + +// GET /api/account/coolify-webhook-attempts?limit=10 +// Recent webhook ingest attempts (success + auth-fail + ip-reject) so the +// user can see whether Coolify is actually reaching them. Capped at 100. +accountRouter.get('/coolify-webhook-attempts', async (c) => { + const userId = c.get('userId') as string; + const limitRaw = c.req.query('limit'); + const limit = limitRaw ? Math.min(100, Math.max(1, Number(limitRaw) || 10)) : 10; + try { + const attempts = await listCoolifyWebhookAttempts(userId, limit); + return c.json({ attempts }); + } catch (err: any) { + console.error('[account] coolify-webhook-attempts GET failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); + +// GET /api/account/coolify-webhook-allowed-ips +accountRouter.get('/coolify-webhook-allowed-ips', async (c) => { + const userId = c.get('userId') as string; + try { + const allowed_ips = await getUserCoolifyWebhookAllowedIps(userId); + return c.json({ allowed_ips }); + } catch (err: any) { + console.error('[account] coolify-webhook-allowed-ips GET failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); + +// PUT /api/account/coolify-webhook-allowed-ips { allowed_ips: string } +accountRouter.put('/coolify-webhook-allowed-ips', async (c) => { + const userId = c.get('userId') as string; + let body: any; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'bad_json' }, 400); + } + const raw = typeof body?.allowed_ips === 'string' ? body.allowed_ips : ''; + try { + const saved = await setUserCoolifyWebhookAllowedIps(userId, raw); + return c.json({ allowed_ips: saved }); + } catch (err: any) { + const msg = String(err?.message ?? err); + if (msg.startsWith('invalid_cidr_entry:')) { + return c.json({ error: 'invalid_cidr', detail: msg.slice('invalid_cidr_entry:'.length).trim() }, 400); + } + console.error('[account] coolify-webhook-allowed-ips PUT failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); diff --git a/hub/src/api/coolify-webhook.ts b/hub/src/api/coolify-webhook.ts index eaf085e..e858c24 100644 --- a/hub/src/api/coolify-webhook.ts +++ b/hub/src/api/coolify-webhook.ts @@ -1,44 +1,85 @@ /** - * Coolify deployment webhook ingress (Phase 06 / G2 + G5). + * Coolify deployment webhook ingress. * - * Public route — auth is per-user HMAC over the raw body. Coolify is configured - * to POST here with: - * - X-Coolify-Signature: sha256= (HMAC-SHA256 over `${ts}.${rawBody}`) - * - X-Coolify-Timestamp: + * Two authentication paths: * - * Flow: - * 1. read raw body BEFORE any JSON parse (HMAC verification needs exact bytes) - * 2. reject if signature/timestamp headers missing - * 3. reject if timestamp skew > 5 minutes - * 4. load per-user secret from users.coolify_webhook_secret - * 5. constant-time compare against expected signature - * 6. Zod-validate payload - * 7. insert a scheduled_task_runs row with deployment metadata - * - deployment.failed → status='pending' + dispatchTriageStub() - * - deployment.succeeded → status='success' (metadata only, no spend) - * - deployment.in_progress→ status='success' (metadata only) - * 8. respond 202 { ok: true, run_id } + * (A) URL-path token (primary, post-fix/coolify-webhook-url-token): + * POST /api/coolify/webhook/:user_id/:token + * `token` IS the `users.coolify_webhook_secret` UUID. Constant-time + * compared. This matches Coolify's actual webhook UI which only exposes + * a single URL field — no headers, no signing. * - * Triage routing (plan 008) replaces the body of `dispatchTriageStub` with the - * real session-target picker — this file ships the ingress + storage. + * (B) Legacy HMAC (deprecated, kept 30 days): + * POST /api/coolify/webhook/:user_id + * Verifies `X-Coolify-Signature: sha256=` HMAC over `${ts}.${rawBody}` + * with 5-min skew window. Returns 200/202 with `Deprecation: true` header + * + a warning log. Existing pre-fix integrations keep working until the + * user re-rotates to get the new URL. + * + * Common pipeline after auth: + * 1. Optional IP allowlist check (users.coolify_webhook_allowed_ips). + * 2. Zod-validate payload. + * 3. Insert scheduled_task_runs row with deployment metadata. + * 4. On deployment.failed → fire-and-forget triage dispatch. + * 5. Record an audit row in coolify_webhook_attempts (every hit, even fails). + * 6. Respond 202 { ok, run_id }. + * + * Audit row policy: + * - SUCCESS rows include the inserted run_id (via reason field). + * - AUTH-FAIL rows record the failed path so users can see "wrong token" + * hits in the UI rather than silence. + * - We NEVER store the wrong token or the full body — preview only. */ import { Hono } from 'hono' import { createHmac, timingSafeEqual } from 'node:crypto' import { z } from 'zod' import { getUserCoolifyWebhookSecret, + getUserCoolifyWebhookConfig, ensureInternalDeploymentTask, ensureInternalTriageTask, insertDeploymentRun, + recordCoolifyWebhookAttempt, } from '../db/dal.ts' import { runNow as dispatcherRunNow } from '../scheduler/dispatcher.ts' +import { ipAllowed, sourceIpFromHeaders } from '../lib/cidr.ts' export const coolifyWebhookRoutes = new Hono() const SKEW_SECONDS = 300 +/** + * Coolify's `SendWebhookJob` emits underscore event names (`deployment_success`, + * `deployment_failed`), not the dotted form. Older docs/examples use dotted. + * Accept both at the wire and normalize to the dotted form internally so the + * rest of the pipeline (status mapping, triage gating) keeps one canonical shape. + */ +const DOTTED_EVENT = z.enum(['deployment.failed', 'deployment.succeeded', 'deployment.in_progress']) +type DottedEvent = z.infer + +const EVENT_ALIAS: Record = { + 'deployment.failed': 'deployment.failed', + 'deployment.succeeded': 'deployment.succeeded', + 'deployment.in_progress': 'deployment.in_progress', + // Coolify SendWebhookJob underscore forms: + deployment_failed: 'deployment.failed', + deployment_success: 'deployment.succeeded', + deployment_succeeded: 'deployment.succeeded', + deployment_in_progress: 'deployment.in_progress', +} + const CoolifyWebhookPayload = z.object({ - event: z.enum(['deployment.failed', 'deployment.succeeded', 'deployment.in_progress']), + event: z + .string() + .min(1) + .transform((s, ctx) => { + const mapped = EVENT_ALIAS[s] + if (!mapped) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `unsupported_event: ${s}` }) + return z.NEVER + } + return mapped + }), deployment_uuid: z.string().min(1), application_uuid: z.string().min(1), git_repository: z.string().optional(), @@ -47,21 +88,6 @@ const CoolifyWebhookPayload = z.object({ export type CoolifyWebhookPayload = z.infer -/** - * Phase 06 plan 008 — fires a real triage run through the scheduler: - * 1. lazy-create the per-user internal triage task (task_type='triage', disabled) - * 2. dispatcher.runNow with payloadOverride carrying deployment metadata - * 3. senders/triage.ts picks a target via pickSessionTarget, dispatches the - * rendered prompt, and finalizes on the next assistant message - * - * `deploymentRunId` is the metadata row stored at webhook ingress; kept for - * UI telemetry. The triage run is a separate row owned by the triage task. - * Cost-cap is enforced inside dispatcher.runNow → fireTask. - * - * Note: log_snippet is empty — the webhook body does not carry logs. The - * model triages on metadata + repo context. A future enhancement can pull - * logs via the Coolify API before dispatch. - */ export async function dispatchTriage( userId: string, deploymentRunId: string, @@ -81,7 +107,7 @@ export async function dispatchTriage( }) } -// Back-compat export kept for tests that referenced the stub by name. +// Back-compat export. export const dispatchTriageStub = dispatchTriage function constantTimeEqualStr(a: string, b: string): boolean { @@ -91,56 +117,72 @@ function constantTimeEqualStr(a: string, b: string): boolean { return timingSafeEqual(aBuf, bBuf) } -coolifyWebhookRoutes.post('/webhook/:user_id', async (c) => { - const userId = c.req.param('user_id') - - // (1) Raw body — MUST happen before any JSON parse so HMAC sees exact bytes. - const rawBody = await c.req.text() - - // (2) Required headers. - const sigHeader = c.req.header('x-coolify-signature') - const tsHeader = c.req.header('x-coolify-timestamp') - if (!sigHeader || !tsHeader) { - return c.json({ error: 'missing_signature' }, 401) - } - - // (3) Skew check. - const ts = Number(tsHeader) - if (!Number.isFinite(ts)) { - return c.json({ error: 'bad_timestamp' }, 401) - } - const nowSec = Math.floor(Date.now() / 1000) - if (Math.abs(nowSec - ts) > SKEW_SECONDS) { - return c.json({ error: 'stale_timestamp' }, 401) +/** + * Best-effort attempt logger. Wrapped in try/catch so a logging failure + * never breaks the webhook response path. + */ +async function logAttempt( + userId: string, + sourceIp: string | null, + eventType: string | null, + status: Parameters[0]['status'], + reason: string | null, + rawBodyPreview: string | null, +): Promise { + try { + await recordCoolifyWebhookAttempt({ + user_id: userId, + source_ip: sourceIp, + event_type: eventType, + status, + reason, + raw_body_preview: rawBodyPreview, + }) + } catch (err: any) { + console.warn('[coolify-webhook] audit log failed:', err?.message) } +} - // (4) Per-user secret. - const secret = await getUserCoolifyWebhookSecret(userId) - if (!secret) { - return c.json({ error: 'webhook_not_configured' }, 401) - } +/** + * Shared post-auth pipeline — payload validate → optional IP gate → + * persist run → optional triage → audit row. + * + * `legacy` only affects logging (so we can see HMAC traffic distinctly) + * and the response header. `sourceIp` is the canonical IP we'll allowlist-check. + */ +async function handleAuthenticated(opts: { + userId: string + rawBody: string + sourceIp: string | null + allowedIps: string[] + legacy: boolean +}) { + const { userId, rawBody, sourceIp, allowedIps, legacy } = opts + const preview = rawBody.slice(0, 500) - // (5) HMAC verify with constant-time compare. - const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex') - if (!constantTimeEqualStr(sigHeader, expected)) { - return c.json({ error: 'bad_signature' }, 401) + // (1) IP allowlist (only if user has configured one). + if (allowedIps.length > 0 && !ipAllowed(sourceIp, allowedIps)) { + await logAttempt(userId, sourceIp, null, 'ip_rejected', 'source_ip_not_in_allowlist', preview) + return { status: 403 as const, body: { error: 'ip_not_allowed' } } } - // (6) Validate payload. + // (2) Validate payload. let parsedBody: unknown try { parsedBody = JSON.parse(rawBody) } catch { - return c.json({ error: 'bad_json' }, 400) + await logAttempt(userId, sourceIp, null, 'bad_payload', 'invalid_json', preview) + return { status: 400 as const, body: { error: 'bad_json' } } } const result = CoolifyWebhookPayload.safeParse(parsedBody) if (!result.success) { - return c.json({ error: 'bad_payload', issues: result.error.issues }, 400) + const eventType = typeof (parsedBody as any)?.event === 'string' ? (parsedBody as any).event : null + await logAttempt(userId, sourceIp, eventType, 'bad_payload', 'schema_validation_failed', preview) + return { status: 400 as const, body: { error: 'bad_payload', issues: result.error.issues } } } const payload = result.data - // (7) Persist deployment metadata. task_id FK is NOT NULL, so we lazily - // ensure a per-user internal "deployment" task row and attach runs to it. + // (3) Persist deployment metadata row. const taskId = await ensureInternalDeploymentTask(userId) const status: 'pending' | 'success' = payload.event === 'deployment.failed' ? 'pending' : 'success' @@ -155,14 +197,125 @@ coolifyWebhookRoutes.post('/webhook/:user_id', async (c) => { commit_sha: payload.commit_sha ?? null, }) + // (4) Triage on failed deploys. if (payload.event === 'deployment.failed') { - // Fire-and-forget — webhook responds 202 immediately. Triage failures - // surface in scheduled_task_runs and the WS run-finished broadcast. void dispatchTriage(userId, run.id, payload).catch((err: any) => { console.warn('[coolify-webhook] triage dispatch failed:', err?.message) }) } - // (8) Accepted. - return c.json({ ok: true, run_id: run.id }, 202) + // (5) Audit row — success. Reason carries the run_id for UI cross-ref. + await logAttempt( + userId, + sourceIp, + payload.event, + legacy ? 'legacy_hmac' : 'success', + `run_id=${run.id}`, + preview, + ) + + return { status: 202 as const, body: { ok: true, run_id: run.id }, legacy } +} + +// ── (A) Primary route: URL-path token ─────────────────────────────────────── + +coolifyWebhookRoutes.post('/webhook/:user_id/:token', async (c) => { + const userId = c.req.param('user_id') + const token = c.req.param('token') + const rawBody = await c.req.text() + const sourceIp = sourceIpFromHeaders({ + get: (n: string) => c.req.header(n) ?? null, + }) + + // Look up config (secret + allowlist) in one round-trip. + const cfg = await getUserCoolifyWebhookConfig(userId).catch(() => ({ + secret: null, + allowedIps: [] as string[], + })) + + // Constant-time compare. Always run compare against a dummy when missing + // so timing reveals neither "user not found" nor "no secret set". + const expected = cfg.secret ?? '00000000-0000-0000-0000-000000000000' + const match = constantTimeEqualStr(token, expected) + if (!cfg.secret || !match) { + await logAttempt( + userId, + sourceIp, + null, + 'auth_failed', + cfg.secret ? 'token_mismatch' : 'webhook_not_configured', + rawBody.slice(0, 500), + ) + // Identical response shape regardless of cause — no enumeration. + return c.json({ error: 'unauthorized' }, 401) + } + + const result = await handleAuthenticated({ + userId, + rawBody, + sourceIp, + allowedIps: cfg.allowedIps, + legacy: false, + }) + return c.json(result.body, result.status) +}) + +// ── (B) Legacy HMAC route — DEPRECATED, kept 30 days ──────────────────────── + +coolifyWebhookRoutes.post('/webhook/:user_id', async (c) => { + const userId = c.req.param('user_id') + const rawBody = await c.req.text() + const sourceIp = sourceIpFromHeaders({ + get: (n: string) => c.req.header(n) ?? null, + }) + + console.warn( + '[coolify-webhook] DEPRECATED legacy HMAC route hit by user', + userId, + '— ask user to re-rotate to migrate to URL-token auth.', + ) + c.header('Deprecation', 'true') + c.header('Sunset', new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toUTCString()) + c.header('Link', '; rel="deprecation"') + + const sigHeader = c.req.header('x-coolify-signature') + const tsHeader = c.req.header('x-coolify-timestamp') + if (!sigHeader || !tsHeader) { + await logAttempt(userId, sourceIp, null, 'auth_failed', 'legacy_missing_signature', rawBody.slice(0, 500)) + return c.json({ error: 'missing_signature' }, 401) + } + + const ts = Number(tsHeader) + if (!Number.isFinite(ts)) { + await logAttempt(userId, sourceIp, null, 'auth_failed', 'legacy_bad_timestamp', rawBody.slice(0, 500)) + return c.json({ error: 'bad_timestamp' }, 401) + } + const nowSec = Math.floor(Date.now() / 1000) + if (Math.abs(nowSec - ts) > SKEW_SECONDS) { + await logAttempt(userId, sourceIp, null, 'auth_failed', 'legacy_stale_timestamp', rawBody.slice(0, 500)) + return c.json({ error: 'stale_timestamp' }, 401) + } + + const secret = await getUserCoolifyWebhookSecret(userId) + if (!secret) { + await logAttempt(userId, sourceIp, null, 'auth_failed', 'webhook_not_configured', rawBody.slice(0, 500)) + return c.json({ error: 'webhook_not_configured' }, 401) + } + + const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex') + if (!constantTimeEqualStr(sigHeader, expected)) { + await logAttempt(userId, sourceIp, null, 'auth_failed', 'legacy_bad_signature', rawBody.slice(0, 500)) + return c.json({ error: 'bad_signature' }, 401) + } + + // Allowlist on legacy too — same defense-in-depth applies. + const cfg = await getUserCoolifyWebhookConfig(userId).catch(() => ({ secret, allowedIps: [] as string[] })) + const result = await handleAuthenticated({ + userId, + rawBody, + sourceIp, + allowedIps: cfg.allowedIps, + legacy: true, + }) + return c.json(result.body, result.status) }) diff --git a/hub/src/db/dal.ts b/hub/src/db/dal.ts index d7a4c35..fa8d18b 100644 --- a/hub/src/db/dal.ts +++ b/hub/src/db/dal.ts @@ -356,6 +356,126 @@ export async function getUserCoolifyWebhookSecret(userId: string): Promise { + const rows = await sql<{ coolify_webhook_secret: string | null; coolify_webhook_allowed_ips: string | null }[]>` + SELECT coolify_webhook_secret, coolify_webhook_allowed_ips + FROM users WHERE id = ${userId} + `; + if (!rows[0]) return { secret: null, allowedIps: [] }; + const csv = rows[0].coolify_webhook_allowed_ips; + let allowedIps: string[] = []; + if (csv && csv.trim()) { + // Defensive: stored data is already validated on write, but parse safely. + try { + const { parseAllowlist } = await import('../lib/cidr.ts'); + allowedIps = parseAllowlist(csv); + } catch { + allowedIps = []; + } + } + return { secret: rows[0].coolify_webhook_secret, allowedIps }; +} + +export async function getUserCoolifyWebhookAllowedIps(userId: string): Promise { + const rows = await sql<{ coolify_webhook_allowed_ips: string | null }[]>` + SELECT coolify_webhook_allowed_ips FROM users WHERE id = ${userId} + `; + return rows[0]?.coolify_webhook_allowed_ips ?? ''; +} + +/** + * Validates + persists the allowlist. Empty string clears the column (NULL). + * Throws Error('invalid_cidr_entry: ') on any bad input. + * Returns the cleaned CSV that was actually saved. + */ +export async function setUserCoolifyWebhookAllowedIps( + userId: string, + raw: string, +): Promise { + const { parseAllowlist } = await import('../lib/cidr.ts'); + const entries = raw && raw.trim() ? parseAllowlist(raw) : []; + const csv = entries.length ? entries.join(',') : null; + await sql`UPDATE users SET coolify_webhook_allowed_ips = ${csv}, updated_at = now() WHERE id = ${userId}`; + return csv ?? ''; +} + +// ── fix/coolify-webhook-url-token (Part 2): webhook attempt audit log ──────── + +export type CoolifyAttemptStatus = + | 'success' + | 'auth_failed' + | 'ip_rejected' + | 'bad_payload' + | 'rate_limited' + | 'legacy_hmac'; + +export interface CoolifyAttemptInput { + user_id: string; + source_ip: string | null; + event_type: string | null; + status: CoolifyAttemptStatus; + reason: string | null; + raw_body_preview: string | null; +} + +export interface CoolifyAttemptRow { + id: string; + received_at: string; + source_ip: string | null; + event_type: string | null; + status: string; + reason: string | null; +} + +/** + * Insert one attempt row, then trim the user's history back to the 100 most + * recent rows. Both ops run independently; failure of the trim never + * blocks the insert response. + */ +export async function recordCoolifyWebhookAttempt(input: CoolifyAttemptInput): Promise { + const preview = input.raw_body_preview ? input.raw_body_preview.slice(0, 500) : null; + await sql` + INSERT INTO coolify_webhook_attempts + (user_id, source_ip, event_type, status, reason, raw_body_preview) + VALUES + (${input.user_id}, ${input.source_ip}, ${input.event_type}, ${input.status}, ${input.reason}, ${preview}) + `; + // Trim — keep last 100. + try { + await sql` + DELETE FROM coolify_webhook_attempts + WHERE user_id = ${input.user_id} + AND id IN ( + SELECT id FROM coolify_webhook_attempts + WHERE user_id = ${input.user_id} + ORDER BY received_at DESC + OFFSET 100 + ) + `; + } catch (err: any) { + console.warn('[coolify-webhook] attempts trim failed:', err?.message); + } +} + +export async function listCoolifyWebhookAttempts(userId: string, limit: number): Promise { + const safeLimit = Math.max(1, Math.min(100, Math.floor(limit))); + const rows = await sql` + SELECT id, received_at, source_ip, event_type, status, reason + FROM coolify_webhook_attempts + WHERE user_id = ${userId} + ORDER BY received_at DESC + LIMIT ${safeLimit} + `; + return rows; +} + /** * Lazily find-or-create the per-user internal scheduled_tasks anchor used for * deployment-event runs. `scheduled_task_runs.task_id` is NOT NULL, so every diff --git a/hub/src/db/schema.sql b/hub/src/db/schema.sql index bf8b229..e1c5f6b 100644 --- a/hub/src/db/schema.sql +++ b/hub/src/db/schema.sql @@ -425,3 +425,24 @@ ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS application_uuid TEXT; ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS git_repository TEXT; ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS commit_sha TEXT; +-- ── fix/coolify-webhook-url-token: IP allowlist (Part 3) ───────────────────── +-- Optional comma-separated IPv4 / IPv6 / CIDR list. NULL = allow all (back-compat). +ALTER TABLE users ADD COLUMN IF NOT EXISTS coolify_webhook_allowed_ips TEXT; + +-- ── fix/coolify-webhook-url-token: webhook attempt audit log (Part 2) ──────── +-- Every hit (success + auth-fail + ip-reject) is logged so the user can see +-- in the UI whether Coolify is actually reaching them. Capped at 100 rows/user +-- via app-side delete-oldest in the same transaction as insert. +CREATE TABLE IF NOT EXISTS coolify_webhook_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source_ip TEXT, + event_type TEXT, + status TEXT NOT NULL, -- 'success' | 'auth_failed' | 'ip_rejected' | 'bad_payload' | 'rate_limited' | 'legacy_hmac' + reason TEXT, + raw_body_preview TEXT -- first 500 chars; never the token/secret +); +CREATE INDEX IF NOT EXISTS idx_coolify_webhook_attempts_user_recv + ON coolify_webhook_attempts(user_id, received_at DESC); + diff --git a/hub/src/lib/cidr.ts b/hub/src/lib/cidr.ts new file mode 100644 index 0000000..b22f364 --- /dev/null +++ b/hub/src/lib/cidr.ts @@ -0,0 +1,160 @@ +/** + * Tiny CIDR / IP allowlist helper. + * + * Supports: + * - Plain IPv4: "46.224.61.233" + * - IPv4 CIDR: "10.0.0.0/8" + * - Plain IPv6: "2001:db8::1" + * - IPv6 CIDR: "2001:db8::/32" + * + * No external deps — small, audited surface. Used by the Coolify webhook + * ingress to enforce the per-user `coolify_webhook_allowed_ips` allowlist. + */ + +function parseIpv4(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let n = 0; + for (const p of parts) { + if (!/^\d{1,3}$/.test(p)) return null; + const v = Number(p); + if (v < 0 || v > 255) return null; + n = (n << 8) | v; + } + // >>> 0 to keep unsigned. + return n >>> 0; +} + +function parseIpv6(ip: string): bigint | null { + // Strip optional zone id (fe80::1%eth0). + const cleaned = ip.split('%')[0]; + // Collapse `::`. + if (cleaned.indexOf('::') !== cleaned.lastIndexOf('::')) return null; + let head: string[] = []; + let tail: string[] = []; + if (cleaned.includes('::')) { + const [h, t] = cleaned.split('::'); + head = h ? h.split(':') : []; + tail = t ? t.split(':') : []; + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + head = head.concat(Array(missing).fill('0')); + } else { + head = cleaned.split(':'); + if (head.length !== 8) return null; + } + const all = head.concat(tail); + if (all.length !== 8) return null; + let n = 0n; + for (const g of all) { + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null; + n = (n << 16n) | BigInt(parseInt(g, 16)); + } + return n; +} + +export function isValidIpOrCidr(entry: string): boolean { + const e = entry.trim(); + if (!e) return false; + const slash = e.indexOf('/'); + if (slash === -1) { + return parseIpv4(e) !== null || parseIpv6(e) !== null; + } + const addr = e.slice(0, slash); + const bitsStr = e.slice(slash + 1); + if (!/^\d+$/.test(bitsStr)) return false; + const bits = Number(bitsStr); + if (parseIpv4(addr) !== null) return bits >= 0 && bits <= 32; + if (parseIpv6(addr) !== null) return bits >= 0 && bits <= 128; + return false; +} + +/** + * Parse a comma-separated allowlist string, dropping blanks and validating + * every entry. Throws Error('invalid_cidr_entry: ') on the first bad one. + * Returns the cleaned canonical CSV (trimmed, deduped, preserving order). + */ +export function parseAllowlist(input: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of input.split(',')) { + const e = raw.trim(); + if (!e) continue; + if (!isValidIpOrCidr(e)) { + throw new Error(`invalid_cidr_entry: ${e}`); + } + if (!seen.has(e)) { + seen.add(e); + out.push(e); + } + } + return out; +} + +function ipv4Match(ip: number, cidrAddr: number, bits: number): boolean { + if (bits === 0) return true; + const mask = bits === 32 ? 0xffffffff : ((0xffffffff << (32 - bits)) >>> 0); + return (ip & mask) === (cidrAddr & mask); +} + +function ipv6Match(ip: bigint, cidrAddr: bigint, bits: number): boolean { + if (bits === 0) return true; + const shift = 128 - bits; + const mask = shift === 0 ? (1n << 128n) - 1n : ((1n << 128n) - 1n) ^ ((1n << BigInt(shift)) - 1n); + return (ip & mask) === (cidrAddr & mask); +} + +/** + * Check whether `sourceIp` falls within any entry of `allowlist`. + * Empty / null allowlist → returns true (back-compat: allow-all). + * Unknown / unparseable `sourceIp` → returns false (deny). + */ +export function ipAllowed(sourceIp: string | null | undefined, allowlist: string[] | null | undefined): boolean { + if (!allowlist || allowlist.length === 0) return true; + if (!sourceIp) return false; + const ip4 = parseIpv4(sourceIp); + const ip6 = ip4 === null ? parseIpv6(sourceIp) : null; + if (ip4 === null && ip6 === null) return false; + for (const entry of allowlist) { + const slash = entry.indexOf('/'); + if (slash === -1) { + // Exact match. + if (ip4 !== null && parseIpv4(entry) === ip4) return true; + if (ip6 !== null) { + const e6 = parseIpv6(entry); + if (e6 !== null && e6 === ip6) return true; + } + continue; + } + const addr = entry.slice(0, slash); + const bits = Number(entry.slice(slash + 1)); + if (ip4 !== null) { + const cidrAddr = parseIpv4(addr); + if (cidrAddr !== null && ipv4Match(ip4, cidrAddr, bits)) return true; + } + if (ip6 !== null) { + const cidrAddr = parseIpv6(addr); + if (cidrAddr !== null && ipv6Match(ip6, cidrAddr, bits)) return true; + } + } + return false; +} + +/** + * Extract the source IP from a Hono request — matches the order the rest of + * the hub uses (cf-connecting-ip → x-real-ip → first x-forwarded-for hop). + */ +export function sourceIpFromHeaders(headers: { + get(name: string): string | null | undefined; +}): string | null { + const cf = headers.get('cf-connecting-ip'); + if (cf) return cf.trim(); + const real = headers.get('x-real-ip'); + if (real) return real.trim(); + const xff = headers.get('x-forwarded-for'); + if (xff) { + const first = xff.split(',')[0]?.trim(); + if (first) return first; + } + return null; +} diff --git a/hub/test/cidr.test.ts b/hub/test/cidr.test.ts new file mode 100644 index 0000000..f294581 --- /dev/null +++ b/hub/test/cidr.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from 'bun:test' +import { isValidIpOrCidr, parseAllowlist, ipAllowed, sourceIpFromHeaders } from '../src/lib/cidr.ts' + +describe('isValidIpOrCidr', () => { + test('accepts plain IPv4', () => { + expect(isValidIpOrCidr('46.224.61.233')).toBe(true) + expect(isValidIpOrCidr('0.0.0.0')).toBe(true) + expect(isValidIpOrCidr('255.255.255.255')).toBe(true) + }) + test('accepts IPv4 CIDR', () => { + expect(isValidIpOrCidr('10.0.0.0/8')).toBe(true) + expect(isValidIpOrCidr('192.168.1.0/24')).toBe(true) + expect(isValidIpOrCidr('0.0.0.0/0')).toBe(true) + }) + test('accepts IPv6 and CIDR', () => { + expect(isValidIpOrCidr('::1')).toBe(true) + expect(isValidIpOrCidr('2001:db8::1')).toBe(true) + expect(isValidIpOrCidr('2001:db8::/32')).toBe(true) + }) + test('rejects nonsense', () => { + expect(isValidIpOrCidr('not-an-ip')).toBe(false) + expect(isValidIpOrCidr('999.999.999.999')).toBe(false) + expect(isValidIpOrCidr('10.0.0.0/33')).toBe(false) + expect(isValidIpOrCidr('2001:db8::/129')).toBe(false) + expect(isValidIpOrCidr('')).toBe(false) + }) +}) + +describe('parseAllowlist', () => { + test('parses csv, trims, dedups, preserves order', () => { + expect(parseAllowlist(' 1.2.3.4 ,1.2.3.4, 5.6.7.0/24')).toEqual(['1.2.3.4', '5.6.7.0/24']) + }) + test('empty / blanks → empty array', () => { + expect(parseAllowlist('')).toEqual([]) + expect(parseAllowlist(' , , ')).toEqual([]) + }) + test('throws on bad entry', () => { + expect(() => parseAllowlist('1.2.3.4, not-an-ip')).toThrow(/invalid_cidr_entry/) + }) +}) + +describe('ipAllowed', () => { + test('empty allowlist → allow all', () => { + expect(ipAllowed('1.2.3.4', [])).toBe(true) + expect(ipAllowed('1.2.3.4', null)).toBe(true) + }) + test('exact IPv4 match', () => { + expect(ipAllowed('46.224.61.233', ['46.224.61.233'])).toBe(true) + expect(ipAllowed('46.224.61.234', ['46.224.61.233'])).toBe(false) + }) + test('IPv4 CIDR match', () => { + expect(ipAllowed('10.5.99.42', ['10.0.0.0/8'])).toBe(true) + expect(ipAllowed('11.0.0.1', ['10.0.0.0/8'])).toBe(false) + expect(ipAllowed('192.168.1.5', ['192.168.1.0/24'])).toBe(true) + expect(ipAllowed('192.168.2.5', ['192.168.1.0/24'])).toBe(false) + }) + test('IPv6 exact + CIDR', () => { + expect(ipAllowed('::1', ['::1'])).toBe(true) + expect(ipAllowed('2001:db8::abcd', ['2001:db8::/32'])).toBe(true) + expect(ipAllowed('2001:db9::1', ['2001:db8::/32'])).toBe(false) + }) + test('null sourceIp + non-empty allowlist → deny', () => { + expect(ipAllowed(null, ['1.2.3.4'])).toBe(false) + }) + test('mixed allowlist', () => { + expect(ipAllowed('46.224.61.233', ['10.0.0.0/8', '46.224.61.233'])).toBe(true) + }) +}) + +describe('sourceIpFromHeaders', () => { + const mk = (kv: Record) => ({ + get: (n: string) => kv[n.toLowerCase()] ?? null, + }) + test('prefers cf-connecting-ip', () => { + expect(sourceIpFromHeaders(mk({ 'cf-connecting-ip': '1.1.1.1', 'x-real-ip': '2.2.2.2' }))).toBe('1.1.1.1') + }) + test('falls back to x-real-ip', () => { + expect(sourceIpFromHeaders(mk({ 'x-real-ip': '2.2.2.2' }))).toBe('2.2.2.2') + }) + test('falls back to first x-forwarded-for hop', () => { + expect(sourceIpFromHeaders(mk({ 'x-forwarded-for': '3.3.3.3, 4.4.4.4' }))).toBe('3.3.3.3') + }) + test('all missing → null', () => { + expect(sourceIpFromHeaders(mk({}))).toBe(null) + }) +}) diff --git a/hub/test/coolify-webhook.test.ts b/hub/test/coolify-webhook.test.ts index 6083b20..0c95e36 100644 --- a/hub/test/coolify-webhook.test.ts +++ b/hub/test/coolify-webhook.test.ts @@ -1,63 +1,61 @@ /** - * Tests for the Coolify deployment webhook ingress (Plan 06-004). + * Tests for the Coolify webhook ingress. * - * Two flavours: - * - Unit cases (always run): mock the DAL module so HMAC verify, skew, - * payload validation, and triage stub branching can be exercised without - * a Postgres connection. Bun's `mock.module()` swaps the import target - * before the route module is dynamically imported. - * - DB-gated cases: skipped unless REMO_E2E_DB_URL is set. Seed a real - * user, post real signed payloads, assert rows land in scheduled_task_runs - * with deployment metadata populated. + * Covers: + * 1. URL-path token auth (primary, post-fix/coolify-webhook-url-token). + * 2. Legacy HMAC auth (deprecated, kept 30 days). + * 3. IP allowlist gating (Part 3). + * 4. Underscore event-name aliasing (Coolify's SendWebhookJob shape). + * 5. Audit log recording for success + every failure path. + * + * DAL is mocked via `mock.module` so no Postgres is needed. */ -import { describe, test, expect, beforeAll, mock, spyOn } from 'bun:test' +import { describe, test, expect, beforeAll, beforeEach, mock } from 'bun:test' import { createHmac } from 'node:crypto' import { Hono } from 'hono' const TEST_USER_ID = '11111111-1111-1111-1111-111111111111' -const TEST_SECRET = 'test-secret-not-real' - -// ── Mock the DAL before importing the route module ────────────────────────── -// Track DAL calls so tests can assert insert payloads. -const dalCalls: { - getUserCoolifyWebhookSecret: string[] - ensureInternalDeploymentTask: string[] - insertDeploymentRun: any[] +const TEST_SECRET = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + +// Per-test mutable mock state. +const mockState: { + secret: string | null + allowedIps: string[] + attempts: any[] + runs: any[] } = { - getUserCoolifyWebhookSecret: [], - ensureInternalDeploymentTask: [], - insertDeploymentRun: [], + secret: TEST_SECRET, + allowedIps: [], + attempts: [], + runs: [], } -// Configurable per-test: which secret the mocked DAL returns. -let mockSecret: string | null = TEST_SECRET - mock.module('../src/db/dal.ts', () => ({ - getUserCoolifyWebhookSecret: async (userId: string) => { - dalCalls.getUserCoolifyWebhookSecret.push(userId) - return mockSecret - }, - ensureInternalDeploymentTask: async (userId: string) => { - dalCalls.ensureInternalDeploymentTask.push(userId) - return 'task-internal-deploy' - }, - // Phase 06 plan 008 — triage task stub. Real dispatch is no-op in unit tests - // because the dispatcher module is also untouched here; tests asserting the - // triage path live in `coolify-webhook-triage-e2e.test.ts`. - ensureInternalTriageTask: async (_userId: string) => 'task-internal-triage', + getUserCoolifyWebhookSecret: async () => mockState.secret, + getUserCoolifyWebhookConfig: async () => ({ + secret: mockState.secret, + allowedIps: mockState.allowedIps, + }), + ensureInternalDeploymentTask: async () => 'task-internal-deploy', + ensureInternalTriageTask: async () => 'task-internal-triage', insertDeploymentRun: async (input: any) => { - dalCalls.insertDeploymentRun.push(input) - return { id: 'run-' + dalCalls.insertDeploymentRun.length } + const row = { id: 'run-' + (mockState.runs.length + 1), ...input } + mockState.runs.push(row) + return row + }, + recordCoolifyWebhookAttempt: async (input: any) => { + mockState.attempts.push(input) }, - // Stubs required so that when other tests import modules transitively - // dependent on dal.ts (e.g. post-run/github-issue.ts), Bun's cached - // mock module still exposes the names they import. + // Stubs for transitive imports. hasOpenIssueForHash: async () => false, recordOpenIssueForHash: async () => {}, })) -// Dynamically import AFTER mock.module is registered so the route binds to -// the mocked DAL. Hono router exposes the handler; mount on a fresh app. +// Also mock the dispatcher so triage dispatch is a no-op. +mock.module('../src/scheduler/dispatcher.ts', () => ({ + runNow: async () => ({ skipped: false }), +})) + let app: Hono let coolifyMod: typeof import('../src/api/coolify-webhook.ts') @@ -67,163 +65,274 @@ beforeAll(async () => { app.route('/api/coolify', coolifyMod.coolifyWebhookRoutes) }) +beforeEach(() => { + mockState.secret = TEST_SECRET + mockState.allowedIps = [] + mockState.attempts.length = 0 + mockState.runs.length = 0 +}) + +function urlTokenPath(userId: string, token: string): string { + return `/api/coolify/webhook/${userId}/${token}` +} + +function legacyPath(userId: string): string { + return `/api/coolify/webhook/${userId}` +} + function sign(secret: string, ts: number, body: string): string { return 'sha256=' + createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex') } -function post(opts: { - userId?: string - body: string - sigHeader?: string | null - tsHeader?: string | null -}) { - const headers: Record = { 'content-type': 'application/json' } - if (opts.sigHeader !== null && opts.sigHeader !== undefined) { - headers['x-coolify-signature'] = opts.sigHeader - } - if (opts.tsHeader !== null && opts.tsHeader !== undefined) { - headers['x-coolify-timestamp'] = opts.tsHeader - } - return app.request(`/api/coolify/webhook/${opts.userId ?? TEST_USER_ID}`, { - method: 'POST', - headers, - body: opts.body, +const validBody = (event = 'deployment.succeeded') => + JSON.stringify({ + event, + deployment_uuid: 'depl-1', + application_uuid: 'app-1', + git_repository: 'https://github.com/x/y', + commit_sha: 'abc123', }) -} -function resetCalls() { - dalCalls.getUserCoolifyWebhookSecret.length = 0 - dalCalls.ensureInternalDeploymentTask.length = 0 - dalCalls.insertDeploymentRun.length = 0 -} +// ── URL-path token auth ───────────────────────────────────────────────────── -describe('coolify-webhook unit cases (mocked DAL)', () => { - test('missing signature header → 401', async () => { - resetCalls() - mockSecret = TEST_SECRET - const ts = Math.floor(Date.now() / 1000) - const body = JSON.stringify({ event: 'deployment.failed', deployment_uuid: 'd', application_uuid: 'a' }) - const res = await post({ body, sigHeader: null, tsHeader: String(ts) }) - expect(res.status).toBe(401) - expect(dalCalls.insertDeploymentRun.length).toBe(0) +describe('coolify-webhook URL-path token auth', () => { + test('correct token + valid body → 202 + run inserted + audit success', async () => { + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody('deployment.succeeded'), + }) + expect(res.status).toBe(202) + const json: any = await res.json() + expect(json.ok).toBe(true) + expect(typeof json.run_id).toBe('string') + expect(mockState.runs.length).toBe(1) + expect(mockState.runs[0].status).toBe('success') + // Audit row recorded. + const audit = mockState.attempts.find((a) => a.status === 'success') + expect(audit).toBeTruthy() + expect(audit.event_type).toBe('deployment.succeeded') }) - test('missing timestamp header → 401', async () => { - resetCalls() - mockSecret = TEST_SECRET - const body = JSON.stringify({ event: 'deployment.failed', deployment_uuid: 'd', application_uuid: 'a' }) - const res = await post({ body, sigHeader: 'sha256=abc', tsHeader: null }) + test('wrong token → 401 + audit auth_failed + no run inserted', async () => { + const res = await app.request(urlTokenPath(TEST_USER_ID, 'bogus-token'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody(), + }) expect(res.status).toBe(401) + expect(mockState.runs.length).toBe(0) + const audit = mockState.attempts.find((a) => a.status === 'auth_failed') + expect(audit).toBeTruthy() + expect(audit.reason).toBe('token_mismatch') }) - test('stale timestamp (>5 min old) → 401', async () => { - resetCalls() - mockSecret = TEST_SECRET - const ts = Math.floor(Date.now() / 1000) - 301 - const body = JSON.stringify({ event: 'deployment.failed', deployment_uuid: 'd', application_uuid: 'a' }) - const res = await post({ body, sigHeader: sign(TEST_SECRET, ts, body), tsHeader: String(ts) }) + test('user has no secret configured → 401 + audit reason webhook_not_configured', async () => { + mockState.secret = null + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody(), + }) expect(res.status).toBe(401) - expect(dalCalls.insertDeploymentRun.length).toBe(0) + const audit = mockState.attempts.find((a) => a.status === 'auth_failed') + expect(audit?.reason).toBe('webhook_not_configured') }) - test('user has no webhook secret → 401', async () => { - resetCalls() - mockSecret = null - const ts = Math.floor(Date.now() / 1000) - const body = JSON.stringify({ event: 'deployment.failed', deployment_uuid: 'd', application_uuid: 'a' }) - const res = await post({ body, sigHeader: sign(TEST_SECRET, ts, body), tsHeader: String(ts) }) - expect(res.status).toBe(401) - const json: any = await res.json() - expect(json.error).toBe('webhook_not_configured') + test('missing token path segment → 404 (route does not match)', async () => { + // Hitting /webhook/:user_id with no token goes to the LEGACY route, which + // requires HMAC headers — missing headers there → 401, not 404. So the + // contract here is: the URL-token route requires the token segment; + // missing it falls through to the legacy handler. + const res = await app.request(legacyPath(TEST_USER_ID), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody(), + }) + expect(res.status).toBe(401) // legacy missing signature }) - test('wrong-secret signature → 401 even when fresh', async () => { - resetCalls() - mockSecret = TEST_SECRET - const ts = Math.floor(Date.now() / 1000) - const body = JSON.stringify({ event: 'deployment.failed', deployment_uuid: 'd', application_uuid: 'a' }) - const sig = sign('a-different-secret', ts, body) - const res = await post({ body, sigHeader: sig, tsHeader: String(ts) }) - expect(res.status).toBe(401) - const json: any = await res.json() - expect(json.error).toBe('bad_signature') - expect(dalCalls.insertDeploymentRun.length).toBe(0) + test('deployment.failed → status=pending + dispatch attempted', async () => { + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody('deployment.failed'), + }) + expect(res.status).toBe(202) + expect(mockState.runs[0].status).toBe('pending') }) +}) - test('valid deployment.succeeded → 202 + status=success row', async () => { - resetCalls() - mockSecret = TEST_SECRET - const ts = Math.floor(Date.now() / 1000) +// ── Underscore event-name aliasing (Coolify SendWebhookJob) ───────────────── + +describe('coolify-webhook event name aliasing', () => { + test('deployment_success (underscore) → normalized to deployment.succeeded → 202', async () => { const body = JSON.stringify({ - event: 'deployment.succeeded', - deployment_uuid: 'depl-1', - application_uuid: 'app-1', - git_repository: 'https://github.com/x/y', - commit_sha: 'abc123', + event: 'deployment_success', + deployment_uuid: 'd', + application_uuid: 'a', + }) + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, }) - const res = await post({ body, sigHeader: sign(TEST_SECRET, ts, body), tsHeader: String(ts) }) expect(res.status).toBe(202) - const json: any = await res.json() - expect(json.ok).toBe(true) - expect(typeof json.run_id).toBe('string') - expect(dalCalls.insertDeploymentRun.length).toBe(1) - const ins = dalCalls.insertDeploymentRun[0] - expect(ins.status).toBe('success') - expect(ins.deployment_uuid).toBe('depl-1') - expect(ins.application_uuid).toBe('app-1') - expect(ins.git_repository).toBe('https://github.com/x/y') - expect(ins.commit_sha).toBe('abc123') - }) - - test('valid deployment.failed → 202 + status=pending + triage stub called', async () => { - resetCalls() - mockSecret = TEST_SECRET - const stubSpy = spyOn(coolifyMod, 'dispatchTriageStub') - stubSpy.mockClear() - const ts = Math.floor(Date.now() / 1000) + expect(mockState.runs[0].status).toBe('success') + const audit = mockState.attempts.find((a) => a.status === 'success') + expect(audit?.event_type).toBe('deployment.succeeded') + }) + + test('deployment_failed (underscore) → normalized → status=pending', async () => { const body = JSON.stringify({ - event: 'deployment.failed', - deployment_uuid: 'depl-2', - application_uuid: 'app-2', + event: 'deployment_failed', + deployment_uuid: 'd', + application_uuid: 'a', + }) + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, }) - const res = await post({ body, sigHeader: sign(TEST_SECRET, ts, body), tsHeader: String(ts) }) expect(res.status).toBe(202) - expect(dalCalls.insertDeploymentRun.length).toBe(1) - expect(dalCalls.insertDeploymentRun[0].status).toBe('pending') - // Note: spyOn may not intercept calls made via the module's internal - // closure reference. If the stub spy didn't fire, fall back to asserting - // the side-effect path was reached (run inserted with status='pending'). - // The stub is a no-op log call — calling it once is the contract. - stubSpy.mockRestore?.() - }) - - test('bad payload (missing required fields) → 400', async () => { - resetCalls() - mockSecret = TEST_SECRET - const ts = Math.floor(Date.now() / 1000) - const body = JSON.stringify({ event: 'deployment.failed' }) // no uuids - const res = await post({ body, sigHeader: sign(TEST_SECRET, ts, body), tsHeader: String(ts) }) + expect(mockState.runs[0].status).toBe('pending') + }) + + test('unknown event → 400 bad_payload', async () => { + const body = JSON.stringify({ + event: 'mystery_event', + deployment_uuid: 'd', + application_uuid: 'a', + }) + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }) expect(res.status).toBe(400) }) }) -// ── Optional DB-gated e2e cases ─────────────────────────────────────────────── -const HAS_TEST_DB = !!process.env.REMO_E2E_DB_URL -const maybe = HAS_TEST_DB ? describe : describe.skip +// ── IP allowlist (Part 3) ─────────────────────────────────────────────────── + +describe('coolify-webhook IP allowlist', () => { + test('empty allowlist → request allowed', async () => { + mockState.allowedIps = [] + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': '8.8.8.8' }, + body: validBody(), + }) + expect(res.status).toBe(202) + }) + + test('source IP in allowlist → allowed', async () => { + mockState.allowedIps = ['46.224.61.233'] + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': '46.224.61.233' }, + body: validBody(), + }) + expect(res.status).toBe(202) + }) + + test('source IP NOT in allowlist → 403 + audit ip_rejected', async () => { + mockState.allowedIps = ['46.224.61.233'] + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': '1.2.3.4' }, + body: validBody(), + }) + expect(res.status).toBe(403) + const audit = mockState.attempts.find((a) => a.status === 'ip_rejected') + expect(audit).toBeTruthy() + expect(audit?.reason).toBe('source_ip_not_in_allowlist') + }) + + test('CIDR range match → allowed', async () => { + mockState.allowedIps = ['10.0.0.0/8'] + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': '10.5.99.42' }, + body: validBody(), + }) + expect(res.status).toBe(202) + }) -maybe('coolify-webhook e2e (REMO_E2E_DB_URL set)', () => { - test('placeholder — wire test DB harness in follow-up', () => { - // TODO: seed a user with coolify_webhook_secret directly via SQL, hit the - // real handler against REMO_E2E_DB_URL, assert scheduled_task_runs row. - expect(true).toBe(true) + test('falls through cf → x-forwarded-for first hop', async () => { + mockState.allowedIps = ['46.224.61.233'] + const res = await app.request(urlTokenPath(TEST_USER_ID, TEST_SECRET), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '46.224.61.233, 10.0.0.1', + }, + body: validBody(), + }) + expect(res.status).toBe(202) }) }) -describe('coolify-webhook harness sanity', () => { - test('e2e is gated on REMO_E2E_DB_URL', () => { - if (!HAS_TEST_DB) { - console.log('[coolify-webhook] REMO_E2E_DB_URL not set — DB cases SKIPPED.') - } - expect(typeof HAS_TEST_DB).toBe('boolean') +// ── Legacy HMAC route (kept 30 days) ──────────────────────────────────────── + +describe('coolify-webhook legacy HMAC route', () => { + test('valid signature → 202 + Deprecation header + audit status=legacy_hmac', async () => { + const ts = Math.floor(Date.now() / 1000) + const body = validBody() + const res = await app.request(legacyPath(TEST_USER_ID), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-coolify-signature': sign(TEST_SECRET, ts, body), + 'x-coolify-timestamp': String(ts), + }, + body, + }) + expect(res.status).toBe(202) + expect(res.headers.get('deprecation')).toBe('true') + expect(res.headers.get('sunset')).toBeTruthy() + const audit = mockState.attempts.find((a) => a.status === 'legacy_hmac') + expect(audit).toBeTruthy() + }) + + test('missing signature header → 401 + audit auth_failed', async () => { + const res = await app.request(legacyPath(TEST_USER_ID), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: validBody(), + }) + expect(res.status).toBe(401) + const audit = mockState.attempts.find((a) => a.status === 'auth_failed') + expect(audit?.reason).toBe('legacy_missing_signature') + }) + + test('stale timestamp → 401', async () => { + const ts = Math.floor(Date.now() / 1000) - 1000 + const body = validBody() + const res = await app.request(legacyPath(TEST_USER_ID), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-coolify-signature': sign(TEST_SECRET, ts, body), + 'x-coolify-timestamp': String(ts), + }, + body, + }) + expect(res.status).toBe(401) + }) + + test('wrong-secret signature → 401', async () => { + const ts = Math.floor(Date.now() / 1000) + const body = validBody() + const res = await app.request(legacyPath(TEST_USER_ID), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-coolify-signature': sign('different-secret', ts, body), + 'x-coolify-timestamp': String(ts), + }, + body, + }) + expect(res.status).toBe(401) }) }) diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 2206eab..a259304 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -971,15 +971,69 @@ function InstructionField({ /* ───────── Coolify Webhook (Phase 06 / Plan 005) ───────── */ +type AttemptRow = { + id: string + received_at: string + source_ip: string | null + event_type: string | null + status: string + reason: string | null +} + +function formatAgo(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + if (ms < 0) return 'just now' + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +function attemptStatusClasses(status: string): string { + if (status === 'success') return 'bg-emerald-500/20 ring-1 ring-emerald-500/30 text-emerald-300' + if (status === 'legacy_hmac') return 'bg-amber-500/20 ring-1 ring-amber-500/30 text-amber-300' + if (status === 'auth_failed' || status === 'ip_rejected') return 'bg-red-500/20 ring-1 ring-red-500/30 text-red-300' + return 'bg-gray-500/20 ring-1 ring-gray-500/30 text-[var(--text-muted)]' +} + function CoolifyWebhookCard({ token }: { token: string }) { const [loading, setLoading] = useState(true) const [configured, setConfigured] = useState(false) const [webhookUrl, setWebhookUrl] = useState('') const [rotating, setRotating] = useState(false) - const [revealedSecret, setRevealedSecret] = useState(null) - const [copied, setCopied] = useState<'url' | 'secret' | null>(null) + const [copied, setCopied] = useState<'url' | null>(null) const [error, setError] = useState(null) + // Attempts log + const [attempts, setAttempts] = useState([]) + const [attemptsLoading, setAttemptsLoading] = useState(false) + + // Allowlist + const [allowedIps, setAllowedIps] = useState('') + const [allowedIpsSaved, setAllowedIpsSaved] = useState('') + const [savingIps, setSavingIps] = useState(false) + const [ipsError, setIpsError] = useState(null) + const [ipsSavedFlash, setIpsSavedFlash] = useState(false) + + const loadAttempts = async () => { + setAttemptsLoading(true) + try { + const data = await hubFetch<{ attempts: AttemptRow[] }>( + token, + '/api/account/coolify-webhook-attempts?limit=10', + ) + setAttempts(data.attempts ?? []) + } catch { + // Silent — empty state handles this. + } finally { + setAttemptsLoading(false) + } + } + useEffect(() => { let cancelled = false setLoading(true) @@ -994,6 +1048,17 @@ function CoolifyWebhookCard({ token }: { token: string }) { }) .catch((e) => { if (!cancelled) setError(String(e?.message || e)) }) .finally(() => { if (!cancelled) setLoading(false) }) + + // Allowlist + hubFetch<{ allowed_ips: string }>(token, '/api/account/coolify-webhook-allowed-ips') + .then((d) => { + if (cancelled) return + setAllowedIps(d.allowed_ips ?? '') + setAllowedIpsSaved(d.allowed_ips ?? '') + }) + .catch(() => {}) + + void loadAttempts() return () => { cancelled = true } }, [token]) @@ -1006,7 +1071,6 @@ function CoolifyWebhookCard({ token }: { token: string }) { '/api/account/coolify-webhook-secret/rotate', { method: 'POST' }, ) - setRevealedSecret(data.secret) setWebhookUrl(data.webhook_url) setConfigured(true) } catch (e: any) { @@ -1016,7 +1080,33 @@ function CoolifyWebhookCard({ token }: { token: string }) { } } - const copy = async (kind: 'url' | 'secret', text: string) => { + const handleSaveAllowedIps = async () => { + setSavingIps(true) + setIpsError(null) + try { + const data = await hubFetch<{ allowed_ips: string }>( + token, + '/api/account/coolify-webhook-allowed-ips', + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ allowed_ips: allowedIps }), + }, + ) + setAllowedIps(data.allowed_ips) + setAllowedIpsSaved(data.allowed_ips) + setIpsSavedFlash(true) + setTimeout(() => setIpsSavedFlash(false), 1500) + } catch (e: any) { + const msg = String(e?.message || e) + // hubFetch surfaces server error body — show the CIDR detail if present. + setIpsError(msg) + } finally { + setSavingIps(false) + } + } + + const copy = async (kind: 'url', text: string) => { try { await navigator.clipboard.writeText(text) setCopied(kind) @@ -1024,6 +1114,8 @@ function CoolifyWebhookCard({ token }: { token: string }) { } catch {} } + const ipsDirty = allowedIps.trim() !== allowedIpsSaved.trim() + return (
@@ -1039,8 +1131,9 @@ function CoolifyWebhookCard({ token }: { token: string }) {

- Lets Coolify push deploy + container events to your remo-code account so the self-heal pipeline can react. - Paste the URL below into Coolify Notifications → Webhooks, then rotate to get a signing secret. + Lets Coolify push deploy events to your remo-code account so the self-heal pipeline can react. + Generate a webhook URL below and paste it into Coolify Notifications → Webhook (single URL field, no headers required). + The URL itself is the credential — treat it as a secret.

@@ -1063,54 +1156,94 @@ function CoolifyWebhookCard({ token }: { token: string }) {
{/* Rotate button */} -
+
- {configured && !revealedSecret && ( + {configured && ( - The secret is stored hashed and only shown once on rotate. + Rotating invalidates the old URL. You'll need to update Coolify with the new one. )}
- {/* One-time revealed secret */} - {revealedSecret && ( -
-
- Copy this secret now — it is shown only once. -
-
- e.currentTarget.select()} - className="flex-1 px-3 py-2 bg-[var(--bg-primary)]/60 rounded-lg text-xs text-[var(--text-primary)] font-mono" - /> - -
-
- In Coolify, configure two request headers per webhook delivery:
- X-Coolify-Signature: sha256=<hex> — HMAC-SHA256 of the raw body using this secret.
- X-Coolify-Timestamp: <unix-seconds> — request timestamp (rejected if >5min skew). -
-
- )} - {error && (
{error}
)} + + {/* Recent webhook attempts */} +
+
+ + +
+ {attempts.length === 0 ? ( +
+ No webhook attempts yet. Paste the URL into Coolify's Notifications → Webhook, + save, then send a test notification. +
+ ) : ( +
+ {attempts.map((a) => ( +
+ + {a.status} + + + {a.event_type ?? '—'} + + {a.source_ip ?? 'unknown'} + {formatAgo(a.received_at)} + {a.reason && ( + + {a.reason} + + )} +
+ ))} +
+ )} +
+ + {/* Allowed source IPs (defense in depth) */} +
+ +