diff --git a/docs/coolify-webhook-migration.md b/docs/coolify-webhook-migration.md index 378e580..00e475d 100644 --- a/docs/coolify-webhook-migration.md +++ b/docs/coolify-webhook-migration.md @@ -40,6 +40,8 @@ Your Coolify server's IP is likely `46.224.61.233` (titaniumlabs.us). Rejected r 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. +**In-UI deprecation banner.** Every successful legacy-HMAC hit stamps `users.coolify_webhook_legacy_hit_at` (idempotent — just bumps the timestamp). `GET /api/account/coolify-webhook-secret` returns this as `legacy_in_use: boolean` + `legacy_hit_at: string | null`, and the Settings → Coolify Webhook card renders an amber callout above the rotate button telling the user to re-rotate. `POST /api/account/coolify-webhook-secret/rotate` clears the flag in the same UPDATE that mints the new URL-token secret, so the banner disappears on next status fetch. + ### 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`. diff --git a/hub/src/api/account.ts b/hub/src/api/account.ts index ff80b1c..0ea2db9 100644 --- a/hub/src/api/account.ts +++ b/hub/src/api/account.ts @@ -48,6 +48,8 @@ accountRouter.get('/coolify-webhook-secret', async (c) => { configured: status.configured, webhook_url: webhookUrlFor(userId, secret), auth_mode: 'url_token', + legacy_in_use: status.legacy_in_use, + legacy_hit_at: status.legacy_hit_at, }); } catch (err: any) { console.error('[account] coolify-webhook-secret GET failed:', err?.code, err?.message); diff --git a/hub/src/api/coolify-webhook.ts b/hub/src/api/coolify-webhook.ts index e858c24..8c94694 100644 --- a/hub/src/api/coolify-webhook.ts +++ b/hub/src/api/coolify-webhook.ts @@ -40,6 +40,7 @@ import { ensureInternalTriageTask, insertDeploymentRun, recordCoolifyWebhookAttempt, + markUserCoolifyWebhookLegacyHit, } from '../db/dal.ts' import { runNow as dispatcherRunNow } from '../scheduler/dispatcher.ts' import { ipAllowed, sourceIpFromHeaders } from '../lib/cidr.ts' @@ -310,6 +311,13 @@ coolifyWebhookRoutes.post('/webhook/:user_id', async (c) => { // Allowlist on legacy too — same defense-in-depth applies. const cfg = await getUserCoolifyWebhookConfig(userId).catch(() => ({ secret, allowedIps: [] as string[] })) + + // Flag user as still on the legacy HMAC format so the Settings UI can show + // a "rotate to migrate" banner. Best-effort — never block the webhook. + markUserCoolifyWebhookLegacyHit(userId).catch((err: any) => { + console.warn('[coolify-webhook] failed to mark legacy-hit flag:', err?.message) + }) + const result = await handleAuthenticated({ userId, rawBody, diff --git a/hub/src/db/dal.ts b/hub/src/db/dal.ts index 117af4b..c7a05df 100644 --- a/hub/src/db/dal.ts +++ b/hub/src/db/dal.ts @@ -367,9 +367,12 @@ export async function updateUserInstructions( } export async function rotateUserCoolifyWebhookSecret(userId: string): Promise { + // Rotating also clears the legacy-hit flag: the user is moving to URL-token + // auth, so the deprecation banner should disappear on next status fetch. const rows = await sql<{ coolify_webhook_secret: string }[]>` UPDATE users SET coolify_webhook_secret = gen_random_uuid()::text, + coolify_webhook_legacy_hit_at = NULL, updated_at = now() WHERE id = ${userId} RETURNING coolify_webhook_secret @@ -379,13 +382,39 @@ export async function rotateUserCoolifyWebhookSecret(userId: string): Promise { - const rows = await sql<{ configured: boolean }[]>` - SELECT (coolify_webhook_secret IS NOT NULL) AS configured +export async function getUserCoolifyWebhookStatus( + userId: string, +): Promise<{ configured: boolean; legacy_in_use: boolean; legacy_hit_at: string | null }> { + const rows = await sql<{ + configured: boolean; + legacy_hit_at: string | null; + }[]>` + SELECT + (coolify_webhook_secret IS NOT NULL) AS configured, + coolify_webhook_legacy_hit_at AS legacy_hit_at FROM users WHERE id = ${userId} `; - return { configured: !!rows[0]?.configured }; + const row = rows[0]; + const hitAt = row?.legacy_hit_at ?? null; + return { + configured: !!row?.configured, + legacy_in_use: hitAt != null, + legacy_hit_at: hitAt ? new Date(hitAt).toISOString() : null, + }; +} + +/** + * Set/refresh the legacy-HMAC-route-hit marker on the user row. Idempotent — + * each successful hit just bumps the timestamp. Cleared by + * `rotateUserCoolifyWebhookSecret`. + */ +export async function markUserCoolifyWebhookLegacyHit(userId: string): Promise { + await sql` + UPDATE users + SET coolify_webhook_legacy_hit_at = now() + WHERE id = ${userId} + `; } // ── Coolify webhook ingress (Phase 06 / plan 004) ───────────────────────────── diff --git a/hub/src/db/schema.sql b/hub/src/db/schema.sql index 578b1ea..97ef74a 100644 --- a/hub/src/db/schema.sql +++ b/hub/src/db/schema.sql @@ -488,6 +488,13 @@ ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS commit_sha TEXT; -- 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-deprecation-banner ─────────────────────────────────── +-- Set whenever the legacy HMAC route ingests a valid webhook (deprecated +-- format). The Settings UI reads this to surface a "rotate to migrate" amber +-- banner. Cleared on rotate (new URL-token secret minted). NULL = never hit +-- the legacy route OR already migrated. +ALTER TABLE users ADD COLUMN IF NOT EXISTS coolify_webhook_legacy_hit_at TIMESTAMPTZ; + -- ── 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 diff --git a/hub/test/coolify-webhook.test.ts b/hub/test/coolify-webhook.test.ts index daee683..7a5488a 100644 --- a/hub/test/coolify-webhook.test.ts +++ b/hub/test/coolify-webhook.test.ts @@ -23,11 +23,13 @@ const mockState: { allowedIps: string[] attempts: any[] runs: any[] + legacyHitCount: number } = { secret: TEST_SECRET, allowedIps: [], attempts: [], runs: [], + legacyHitCount: 0, } mock.module('../src/db/dal.ts', () => ({ @@ -46,6 +48,9 @@ mock.module('../src/db/dal.ts', () => ({ recordCoolifyWebhookAttempt: async (input: any) => { mockState.attempts.push(input) }, + markUserCoolifyWebhookLegacyHit: async () => { + mockState.legacyHitCount += 1 + }, // Stubs for transitive imports. hasOpenIssueForHash: async () => false, recordOpenIssueForHash: async () => {}, @@ -75,6 +80,7 @@ beforeEach(() => { mockState.allowedIps = [] mockState.attempts.length = 0 mockState.runs.length = 0 + mockState.legacyHitCount = 0 }) function urlTokenPath(userId: string, token: string): string { @@ -298,6 +304,28 @@ describe('coolify-webhook legacy HMAC route', () => { expect(res.headers.get('sunset')).toBeTruthy() const audit = mockState.attempts.find((a) => a.status === 'legacy_hmac') expect(audit).toBeTruthy() + // Successful legacy auth must flag the user so the Settings UI shows the + // "rotate to migrate" banner. markUserCoolifyWebhookLegacyHit is fire-and- + // forget — we await a microtask to let the unawaited promise resolve. + await new Promise((r) => setImmediate(r)) + expect(mockState.legacyHitCount).toBe(1) + }) + + test('failed legacy auth (bad signature) → DOES NOT mark legacy hit', 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('wrong-secret', ts, body), + 'x-coolify-timestamp': String(ts), + }, + body, + }) + expect(res.status).toBe(401) + await new Promise((r) => setImmediate(r)) + expect(mockState.legacyHitCount).toBe(0) }) test('missing signature header → 401 + audit auth_failed', async () => { diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 4e5c8cd..e1b459e 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -988,6 +988,8 @@ function CoolifyWebhookCard({ token }: { token: string }) { const [loading, setLoading] = useState(true) const [configured, setConfigured] = useState(false) const [webhookUrl, setWebhookUrl] = useState('') + const [legacyInUse, setLegacyInUse] = useState(false) + const [legacyHitAt, setLegacyHitAt] = useState(null) const [rotating, setRotating] = useState(false) const [copied, setCopied] = useState<'url' | null>(null) const [error, setError] = useState(null) @@ -1021,7 +1023,12 @@ function CoolifyWebhookCard({ token }: { token: string }) { useEffect(() => { let cancelled = false setLoading(true) - hubFetch<{ configured: boolean; webhook_url: string }>( + hubFetch<{ + configured: boolean + webhook_url: string + legacy_in_use?: boolean + legacy_hit_at?: string | null + }>( token, '/api/account/coolify-webhook-secret', ) @@ -1029,6 +1036,8 @@ function CoolifyWebhookCard({ token }: { token: string }) { if (cancelled) return setConfigured(data.configured) setWebhookUrl(data.webhook_url) + setLegacyInUse(!!data.legacy_in_use) + setLegacyHitAt(data.legacy_hit_at ?? null) }) .catch((e) => { if (!cancelled) setError(String(e?.message || e)) }) .finally(() => { if (!cancelled) setLoading(false) }) @@ -1057,6 +1066,10 @@ function CoolifyWebhookCard({ token }: { token: string }) { ) setWebhookUrl(data.webhook_url) setConfigured(true) + // Rotate clears the legacy-hit flag server-side; mirror locally so the + // banner disappears immediately without a refresh. + setLegacyInUse(false) + setLegacyHitAt(null) } catch (e: any) { setError(String(e?.message || e)) } finally { @@ -1139,6 +1152,25 @@ function CoolifyWebhookCard({ token }: { token: string }) { + {/* Legacy-HMAC deprecation banner */} + {legacyInUse && ( +
+
+ Your Coolify webhook is using the deprecated HMAC format. +
+
+ Click Rotate URL below to mint a new URL-token webhook, + then paste the new URL into Coolify Notifications → Webhook. The legacy route is kept for 30 + days but will be removed. + {legacyHitAt && ( + + Last legacy hit: {new Date(legacyHitAt).toLocaleString()} + + )} +
+
+ )} + {/* Rotate button */}