Files
HealthLog/src/app/api/internal/deploy-webhook/route.ts
T
Marc-André Bombeck 3c3e5ceab7 fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
2026-09-26 15:20:42 +02:00

322 lines
12 KiB
TypeScript

import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { prisma } from "@/lib/db";
/**
* Deploy-status webhook (v1.4.15).
*
* Coolify (Settings → Notifications → Webhook) calls this endpoint after
* every deploy attempt. We verify the request via a shared secret in the
* `X-Deploy-Webhook-Secret` header (timing-safe compare against
* `process.env.DEPLOY_WEBHOOK_SECRET`), audit-log the event, and on
* failure page the admin via Telegram if the channel is configured.
*
* Why a *separate* webhook for status when Coolify already has a deploy
* trigger URL: the trigger is one-shot ("queue a deploy"); the status
* comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
* verification recipe (the maintainer had to SSH and check digests).
*
* Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record<string,
* unknown>` and only extract a small set of well-known keys
* (`status`, `application_name`, `application_uuid`, `deployment_uuid`,
* `error`) defensively, falling back to "unknown" when missing. The
* payload itself is stored in the audit-log `details` field so a
* future Coolify upgrade that adds richer metadata becomes
* automatically available without code change.
*/
interface CoolifyDeployPayload {
status?: unknown;
application_name?: unknown;
application_uuid?: unknown;
deployment_uuid?: unknown;
error?: unknown;
[key: string]: unknown;
}
interface NormalizedEvent {
outcome: "success" | "failure" | "unknown";
applicationName: string;
applicationUuid: string | null;
deploymentUuid: string | null;
error: string | null;
raw: CoolifyDeployPayload;
}
function asStringOr<T extends string | null>(
value: unknown,
fallback: T,
): string | T {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function classifyOutcome(status: unknown): NormalizedEvent["outcome"] {
if (typeof status !== "string") return "unknown";
const s = status.toLowerCase();
// Coolify uses "success" / "finished" for happy path and
// "failed" / "error" / "stopped" for unhappy. Anything else is
// logged but doesn't trigger a Telegram page (avoids alert fatigue
// on intermediate states like "queued" or "in_progress").
if (s === "success" || s === "finished" || s === "succeeded")
return "success";
if (s === "failed" || s === "error" || s === "stopped" || s === "failure") {
return "failure";
}
return "unknown";
}
function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
return {
outcome: classifyOutcome(payload.status),
applicationName: asStringOr(payload.application_name, "unknown"),
applicationUuid: asStringOr(payload.application_uuid, null),
deploymentUuid: asStringOr(payload.deployment_uuid, null),
error: asStringOr(payload.error, null),
raw: payload,
};
}
/**
* Replay protection (v1.16.4). A captured webhook request carries a
* static shared secret, so without a freshness check it can be replayed
* indefinitely — re-paging admins with stale failure alerts and
* polluting the audit log with forged deploy outcomes.
*
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
* are rejected. Two modes:
*
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
* stock notification sender does not attach one, and breaking the
* live integration would cost more than the residual replay window.
* A header that IS present but stale/invalid is always rejected, so
* a replayed capture of a timestamped request never lands.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
* mandatory. For operators whose sender (or reverse proxy) can
* attach the timestamp; closes the replay window completely.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
*/
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
function parseTimestamp(raw: string): number | null {
// Unix seconds (10 digits covers 2001-2286) or milliseconds.
if (/^\d{10}$/.test(raw)) return Number(raw) * 1000;
if (/^\d{13}$/.test(raw)) return Number(raw);
const iso = Date.parse(raw);
return Number.isFinite(iso) ? iso : null;
}
function checkTimestampFreshness(
request: NextRequest,
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
const required =
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
if (raw === null || raw === "") {
if (required) return { ok: false, reason: "timestamp_missing" };
return { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
return { ok: false, reason: "timestamp_outside_window" };
}
return { ok: true };
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("DEPLOY_WEBHOOK_SECRET not configured");
return false;
}
const received = request.headers.get("x-deploy-webhook-secret");
if (!received) return false;
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Reuse the `maybeAlertAdmins()` pattern from
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is
* safe even on a first-deploy where the maintainer hasn't wired the bot yet.
*/
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"No admin user configured to alert about deploy failure",
);
return;
}
// v1.4.27 F21 — each admin sees the alert in their own User.locale.
// The body is composed inside `dispatchLocalisedNotification` from
// translation keys; the static "Error:" / "Deployment:" / "Logs:"
// labels are emitted as a single param-substituted body key so all
// six locale bundles can render them natively. The deployment-uuid
// and error lines fall back to an empty string when missing so the
// surrounding template still composes cleanly.
for (const admin of admins) {
await dispatchLocalisedNotification({
userId: admin.id,
titleKey: "notifications.admin.deployFailedTitle",
messageKey: "notifications.admin.deployFailedBody",
params: {
application: event.applicationName,
error: event.error ?? "",
deployment: event.deploymentUuid ?? "",
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
logsUrl: envOr("DEPLOY_LOGS_URL", ""),
},
metadata: {
source: "deploy-webhook",
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
},
});
}
}
/**
* Rate-limit by client IP. 60 requests / minute is comfortably above
* Coolify's per-event burst (typically 1-2 per deploy) but well below a
* hostile actor flooding to fish for a working secret.
*
* Both verbs share the bucket, and both run it before the comparison: the
* reachability GET checks the same secret and answers 200 against 401, so
* leaving it unlimited would hand back the free guessing channel the POST
* limit exists to close.
*/
async function applyWebhookRateLimit(
request: NextRequest,
): Promise<NextResponse | null> {
const ip = getClientIp(request);
const rl = await checkRateLimit(
`deploy-webhook:${ip ?? "unknown"}`,
60,
60_000,
);
if (rl.allowed) return null;
return NextResponse.json(
{ status: "rate_limited" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
const ip = getClientIp(request);
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy, and a replayed-but-stale request
// from a captured legitimate call is rejected here.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
await safeJson<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const event = normalizePayload(payload);
annotate({
meta: {
deploy_outcome: event.outcome,
application_name: event.applicationName,
application_uuid: event.applicationUuid,
deployment_uuid: event.deploymentUuid,
},
});
// One audit-log row per webhook call. Action namespace mirrors the
// existing `system.*` lane (`system.error`, `system.cleanup.*`) for
// ops-style events that aren't tied to a single user.
const action =
event.outcome === "success"
? "system.deploy.success"
: event.outcome === "failure"
? "system.deploy.failure"
: "system.deploy.unknown";
await auditLog(action, {
ipAddress: ip ?? null,
details: {
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
error: event.error,
// Preserve the full Coolify payload so we can adapt to schema
// drift without code change.
raw: event.raw,
},
});
if (event.outcome === "failure") {
await notifyAdminsOfFailure(event);
}
return NextResponse.json(
{ status: "ok", outcome: event.outcome },
{ status: 200 },
);
});
/**
* Coolify (and most webhook UIs) ping `GET` for a reachability check
* before saving the configuration. We respond 200 if the secret is
* valid, 401 otherwise — same shape as the Withings webhook.
*/
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook.verify" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
});