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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions docs/coolify-webhook-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user_id>/<token>
```

`<token>` 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`.
Expand Down
84 changes: 75 additions & 9 deletions hub/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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=<hex>',
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);
}
});
Loading
Loading