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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/coolify-webhook-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions hub/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions hub/src/api/coolify-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 33 additions & 4 deletions hub/src/db/dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,12 @@ export async function updateUserInstructions(
}

export async function rotateUserCoolifyWebhookSecret(userId: string): Promise<string> {
// 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
Expand All @@ -379,13 +382,39 @@ export async function rotateUserCoolifyWebhookSecret(userId: string): Promise<st
return secret;
}

export async function getUserCoolifyWebhookStatus(userId: string): Promise<{ configured: boolean }> {
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<void> {
await sql`
UPDATE users
SET coolify_webhook_legacy_hit_at = now()
WHERE id = ${userId}
`;
}

// ── Coolify webhook ingress (Phase 06 / plan 004) ─────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions hub/src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions hub/test/coolify-webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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 () => {},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand Down
34 changes: 33 additions & 1 deletion web/src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)
const [rotating, setRotating] = useState(false)
const [copied, setCopied] = useState<'url' | null>(null)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -1021,14 +1023,21 @@ 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',
)
.then((data) => {
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) })
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1139,6 +1152,25 @@ function CoolifyWebhookCard({ token }: { token: string }) {
</div>
</div>

{/* Legacy-HMAC deprecation banner */}
{legacyInUse && (
<div className="rounded-lg bg-amber-500/10 ring-1 ring-amber-500/30 px-3 py-2.5 text-xs text-amber-200">
<div className="font-semibold mb-0.5">
Your Coolify webhook is using the deprecated HMAC format.
</div>
<div className="text-amber-200/80">
Click <span className="font-medium">Rotate URL</span> 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 && (
<span className="block mt-1 text-amber-200/60">
Last legacy hit: {new Date(legacyHitAt).toLocaleString()}
</span>
)}
</div>
</div>
)}

{/* Rotate button */}
<div className="flex items-center gap-3 flex-wrap">
<button
Expand Down
Loading