From 0eb1d47cde4999890aba674e7cce91eb26e1ef7b Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:25:02 +0530 Subject: [PATCH] deploy: optional notify_webhook URL fires HTTP POST on deploy terminal state Adds a new POST /deploy/new field that lets callers subscribe to deploy terminal-state events instead of polling GET /deploy/:id. When the deploy reaches 'healthy' or 'failed', a worker job (separate PR) will POST a payload to the supplied URL, optionally signed with HMAC-SHA256. Migration 026 adds four columns to deployments: notify_webhook, notify_webhook_secret (AES-256-GCM at rest), notify_state ('unset' / 'pending' / 'sent' / 'failed'), notify_attempts. A partial index on (notify_state, status) WHERE notify_state='pending' keeps the worker scan cheap. SSRF gate enforces https-only scheme and rejects hostnames resolving to loopback / RFC1918 / link-local (incl. AWS/GCP metadata 169.254.169.254) / CGNAT / multicast / IPv6 unique-local. Mixed-record DNS attacks are caught: if ANY resolved IP is in a blocked range, the URL is rejected. This PR only persists the fields; the worker-side dispatcher lives in the worker repo and is a separate follow-up. --- internal/db/migrations/026_deploy_webhook.sql | 37 ++ .../handlers/agent_action_contract_test.go | 1 + internal/handlers/deploy.go | 45 ++- internal/handlers/deploy_webhook_notify.go | 237 ++++++++++++ .../deploy_webhook_notify_handler_test.go | 349 ++++++++++++++++++ .../handlers/deploy_webhook_notify_test.go | 207 +++++++++++ internal/handlers/openapi.go | 10 +- internal/models/deployment.go | 109 ++++-- 8 files changed, 956 insertions(+), 39 deletions(-) create mode 100644 internal/db/migrations/026_deploy_webhook.sql create mode 100644 internal/handlers/deploy_webhook_notify.go create mode 100644 internal/handlers/deploy_webhook_notify_handler_test.go create mode 100644 internal/handlers/deploy_webhook_notify_test.go diff --git a/internal/db/migrations/026_deploy_webhook.sql b/internal/db/migrations/026_deploy_webhook.sql new file mode 100644 index 00000000..b9c0b535 --- /dev/null +++ b/internal/db/migrations/026_deploy_webhook.sql @@ -0,0 +1,37 @@ +-- Migration: 026_deploy_webhook — optional notify_webhook on a deployment so +-- the user's external URL gets POST'd when the deploy reaches a terminal +-- state (healthy / failed). Today agents poll GET /deploy/:id to discover +-- success/failure; this lets them subscribe instead. +-- +-- Columns: +-- notify_webhook TEXT — user-supplied URL (https only, SSRF-checked +-- on write). Stored verbatim; not encrypted because +-- it's a hostname-bearing URL that the worker needs +-- to read on every retry. +-- notify_webhook_secret TEXT — optional HMAC signing key. AES-256-GCM +-- encrypted at rest with the platform AES_KEY (same +-- path as resources.connection_url). Worker decrypts +-- before computing the X-InstaNode-Signature header. +-- notify_state TEXT — lifecycle: 'unset' (default, no webhook), +-- 'pending' (terminal-state reached, awaiting POST), +-- 'sent' (2xx received), 'failed' (4xx received, or +-- 5xx/network after max retries). The worker's job +-- scans WHERE notify_state='pending' AND status IN +-- ('healthy','failed'). +-- notify_attempts INTEGER — count of dispatch attempts. Worker +-- caps at 3 for transient 5xx/network errors; +-- 4xx is permanent (don't retry — the URL is +-- broken from the user's side). +-- +-- Index: partial on (notify_state, status) WHERE notify_state='pending' keeps +-- the worker scan cheap as the deployments table grows. Anything not pending +-- is invisible to the scan, so the index stays small. + +ALTER TABLE deployments ADD COLUMN IF NOT EXISTS notify_webhook TEXT; +ALTER TABLE deployments ADD COLUMN IF NOT EXISTS notify_webhook_secret TEXT; +ALTER TABLE deployments ADD COLUMN IF NOT EXISTS notify_state TEXT NOT NULL DEFAULT 'unset'; +ALTER TABLE deployments ADD COLUMN IF NOT EXISTS notify_attempts INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_deployments_notify_pending + ON deployments(notify_state, status) + WHERE notify_state = 'pending'; diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 73372d8b..6ebabf79 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -38,6 +38,7 @@ func agentActionContractCases() map[string]string { "AgentActionPromotionInvalid": AgentActionPromotionInvalid, "AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed, "AgentActionPromotionExpired": AgentActionPromotionExpired, + "AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid, // Builders — representative inputs covering tier/env/role/limit // interpolation. diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 4427061f..ba5f7cd8 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -129,6 +129,17 @@ func deploymentToMap(d *models.Deployment) fiber.Map { "created_at": d.CreatedAt, "updated_at": d.UpdatedAt, "team_id": d.TeamID, + // notify_webhook surface (migration 026): URL is echoed back (the + // caller supplied it, so no secret is leaked); secret + state + + // attempts are emitted only when a webhook is configured so we + // don't pollute the shape for legacy callers. The plaintext + // secret is NEVER returned — only its lifecycle metadata. + "notify_webhook": d.NotifyWebhook, + "notify_state": d.NotifyState, + } + if d.NotifyWebhook != "" { + m["notify_attempts"] = d.NotifyAttempts + m["notify_secret_set"] = d.NotifyWebhookSecret != "" } if d.ErrorMessage != "" { m["error"] = d.ErrorMessage @@ -380,6 +391,22 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { return privErr // respondError already called inside parsePrivateDeployFields } + // ── Notify webhook fields (migration 026) ──────────────────────────────── + // + // Optional async notification: when the deploy reaches a terminal state + // (healthy / failed) the worker POSTs to this URL. SSRF + scheme gate + // fires here, before any DB write, so the row never carries an unsafe + // URL. Secret is AES-256-GCM encrypted before persistence. + // + // Worker-side dispatcher is a separate PR — this PR only persists the + // fields. notify_state defaults to 'pending' when a URL is supplied + // (see CreateDeployment) so the future worker scan picks it up + // immediately on terminal-state arrival. + notifyURL, notifySecret, notifyErr := parseNotifyWebhookFields(c, form, h.cfg.AESKey) + if notifyErr != nil { + return notifyErr // respondError already called inside parseNotifyWebhookFields + } + // ── Tier-limit enforcement (plans.yaml: deployments_apps) ──────────────── // // Count the team's currently-active deployments and reject when over the @@ -406,14 +433,16 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { } saved, err := models.CreateDeployment(c.Context(), h.db, models.CreateDeploymentParams{ - TeamID: team.ID, - AppID: appID, - Port: port, - Tier: team.PlanTier, - Env: environment, - EnvVars: initEnv, - Private: private, - AllowedIPs: allowedIPs, + TeamID: team.ID, + AppID: appID, + Port: port, + Tier: team.PlanTier, + Env: environment, + EnvVars: initEnv, + Private: private, + AllowedIPs: allowedIPs, + NotifyWebhook: notifyURL, + NotifyWebhookSecret: notifySecret, }) if err != nil { slog.Error("deploy.new.db_create_failed", diff --git a/internal/handlers/deploy_webhook_notify.go b/internal/handlers/deploy_webhook_notify.go new file mode 100644 index 00000000..d9504098 --- /dev/null +++ b/internal/handlers/deploy_webhook_notify.go @@ -0,0 +1,237 @@ +package handlers + +// deploy_webhook_notify.go — Optional notify_webhook field on POST /deploy/new. +// +// Today a caller has no async signal that a deploy has reached a terminal +// state (healthy / failed) — they poll GET /deploy/:id. The notify_webhook +// field lets the caller subscribe instead: when the deploy hits 'healthy' +// or 'failed' the worker will POST a payload to the supplied URL (with an +// optional HMAC-SHA256 signature header when notify_webhook_secret is set). +// +// This file owns the *write* path: parsing the multipart fields, validating +// the URL, encrypting the secret. The *dispatch* path (worker scans +// notify_state='pending' rows and POSTs to the URL) is a follow-up PR that +// lives in the worker repo — see the PR description for the contract. +// +// Validation rules (kept loud and explicit because they are the SSRF gate): +// +// 1. Scheme MUST be https. No http://, no file://, no gopher://. An +// agent supplying http:// for "convenience" gets a 400 with a +// copy-pastable agent_action sentence pointing at the docs. +// +// 2. Hostname MUST resolve and MUST NOT resolve to a private / loopback / +// link-local / multicast / unspecified / CGNAT range. This is the +// SSRF safety net: a malicious agent could try +// https://169.254.169.254 (cloud metadata) or +// https://10.0.0.5:8080/admin (internal service). Every resolved IP +// is checked — if ANY resolved IP is in a blocked range we reject the +// whole URL. We deliberately do NOT try to "warn and proceed" — the +// worker dispatches with the platform's egress identity and that +// authority must not point inward. +// +// 3. Hostname literal forms are rejected even before DNS: +// "localhost" (regardless of /etc/hosts), and any IP literal that +// itself parses into a blocked range. This stops an attacker from +// passing 127.0.0.1 as a string literal in the URL. +// +// The SSRF check is intentionally synchronous in the request path. The +// 400 is the right place — the worker shouldn't have to redo the check +// on every retry, and an "accepted, later silently dropped" path is the +// hardest-to-debug failure mode. + +import ( + "fmt" + "mime/multipart" + "net" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" + + "instant.dev/internal/crypto" +) + +// notifyWebhookResolver is overridable so tests can inject a deterministic +// resolver without doing real DNS. Production code uses net.LookupIP. +// +// The signature returns []net.IP so the SSRF check can iterate every +// resolved A/AAAA record — a hostname pointing at one public and one +// private IP must still be rejected (mixed-record SSRF dodge). +var notifyWebhookResolver = func(host string) ([]net.IP, error) { + return net.LookupIP(host) +} + +// SetNotifyWebhookResolverForTest swaps the package-level DNS resolver used +// by validateNotifyWebhookURL. Test-only escape hatch — handler_test (a +// black-box package) can't reach the unexported var directly. The returned +// function restores the previous resolver; tests should `defer` it. +// +// Production code never calls this. The behaviour is identical to writing +// `notifyWebhookResolver = ...` inline, but the explicit name makes it +// easy to grep for test-only mutation in this file. +func SetNotifyWebhookResolverForTest(replacement func(host string) ([]net.IP, error)) func() { + prev := notifyWebhookResolver + notifyWebhookResolver = replacement + return func() { notifyWebhookResolver = prev } +} + +// parseNotifyWebhookFields extracts and validates the optional `notify_webhook` +// and `notify_webhook_secret` multipart fields from POST /deploy/new. +// +// Returns (rawURL, encryptedSecret, nil) on success. On failure, writes the +// 400 response inline and returns a non-nil error — caller MUST propagate +// it and return immediately (mirrors parsePrivateDeployFields). +// +// Behaviour: +// - field absent / empty → ("", "", nil) +// - URL fails SSRF / scheme / parse gate → 400 + agent_action +// - URL ok, secret absent → (url, "", nil) +// - URL ok, secret present, AES key bad → 503 (server-side; not user fault) +// - URL ok, secret present, encrypts fine → (url, ciphertext, nil) +// +// The plaintext secret is never returned to the caller and never persisted +// in plaintext — Encrypt's output is what lands in the deployments row. +func parseNotifyWebhookFields(c *fiber.Ctx, form *multipart.Form, aesKeyHex string) (string, string, error) { + rawURL := strings.TrimSpace(firstFormValue(form, "notify_webhook")) + if rawURL == "" { + // Field absent — nothing to validate, nothing to store. notify_state + // stays at the column default ('unset'). Backward-compatible: any + // existing caller that doesn't know about this field sees no change. + return "", "", nil + } + + if err := validateNotifyWebhookURL(rawURL); err != nil { + return "", "", respondErrorWithAgentAction(c, + fiber.StatusBadRequest, + "invalid_notify_webhook", + err.Error(), + AgentActionNotifyWebhookInvalid, + "") + } + + rawSecret := firstFormValue(form, "notify_webhook_secret") + if rawSecret == "" { + return rawURL, "", nil + } + + // Secret is supplied — encrypt with the platform AES key. Same path as + // resources.connection_url, vault entries, webhook receive URLs. + aesKey, keyErr := crypto.ParseAESKey(aesKeyHex) + if keyErr != nil { + // Operator error — AES_KEY is misconfigured. Surface as 503 because + // the user can't fix it; the platform must. + return "", "", respondError(c, + fiber.StatusServiceUnavailable, + "encryption_unavailable", + "Webhook secret encryption is misconfigured on the server") + } + ciphertext, encErr := crypto.Encrypt(aesKey, rawSecret) + if encErr != nil { + return "", "", respondError(c, + fiber.StatusServiceUnavailable, + "encryption_failed", + "Failed to encrypt webhook secret") + } + return rawURL, ciphertext, nil +} + +// validateNotifyWebhookURL is the SSRF + scheme gate. Pure function — no IO +// other than the DNS lookup via notifyWebhookResolver. Returns an error whose +// message is safe to surface in the 400 body (no internal IPs leaked). +func validateNotifyWebhookURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("notify_webhook is not a valid URL") + } + if u.Scheme != "https" { + return fmt.Errorf("notify_webhook must use https:// (got %q)", u.Scheme) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("notify_webhook is missing a hostname") + } + // Reject "localhost" by literal name before any DNS — /etc/hosts can + // remap it but we never want an inbound URL claiming localhost. + if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { + return fmt.Errorf("notify_webhook hostname is not publicly routable") + } + + // If the host parses as an IP literal, check it directly — no need to + // hit DNS for an IP, and DNS would just resolve to itself. + if ip := net.ParseIP(host); ip != nil { + if isBlockedIP(ip) { + return fmt.Errorf("notify_webhook IP is in a blocked range (private / loopback / link-local)") + } + return nil + } + + // Hostname — resolve and check every resulting IP. A hostname that + // resolves to BOTH a public and a private IP is rejected (the mixed- + // record SSRF dodge: attacker controls DNS, returns 8.8.8.8 + 10.0.0.5). + ips, err := notifyWebhookResolver(host) + if err != nil { + return fmt.Errorf("notify_webhook hostname does not resolve") + } + if len(ips) == 0 { + return fmt.Errorf("notify_webhook hostname has no A/AAAA records") + } + for _, ip := range ips { + if isBlockedIP(ip) { + return fmt.Errorf("notify_webhook hostname resolves to a private / loopback / link-local IP") + } + } + return nil +} + +// isBlockedIP returns true if ip is in any range we refuse to dispatch to. +// +// The set is deliberately broad — anything that isn't unambiguously a public +// internet IP is blocked. This is the SSRF safety net so we err on the side +// of false positives (rejecting weird-but-legal URLs) over false negatives +// (letting the worker POST to cloud metadata). +// +// Blocked: +// - IPv4 loopback 127.0.0.0/8 +// - IPv4 private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 +// - IPv4 link-local 169.254.0.0/16 (covers AWS/GCP metadata 169.254.169.254) +// - IPv4 CGNAT 100.64.0.0/10 +// - IPv4 multicast 224.0.0.0/4 +// - IPv4 broadcast 255.255.255.255 +// - IPv6 loopback ::1 +// - IPv6 unspecified :: +// - IPv6 link-local fe80::/10 +// - IPv6 unique-local fc00::/7 +// - IPv6 multicast ff00::/8 +// - IPv6 IPv4-mapped ::ffff:0:0/96 (re-checked as v4 to catch e.g. ::ffff:127.0.0.1) +// - any unspecified 0.0.0.0 +func isBlockedIP(ip net.IP) bool { + // Standard-library predicates cover most of the surface. We do them + // first because they're the cheapest checks and they hit the common + // SSRF targets (loopback, link-local, multicast, private). + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || + ip.IsUnspecified() || ip.IsPrivate() { + return true + } + // IPv4-mapped IPv6 — re-check as v4 so ::ffff:10.0.0.1 doesn't slip past. + if v4 := ip.To4(); v4 != nil { + // CGNAT 100.64.0.0/10 — not covered by IsPrivate(). Catches the + // shared address space carriers use behind NAT. + _, cgnat, _ := net.ParseCIDR("100.64.0.0/10") + if cgnat.Contains(v4) { + return true + } + // Broadcast 255.255.255.255 — limited-broadcast literal. + if v4.Equal(net.IPv4bcast) { + return true + } + } + return false +} + +// AgentActionNotifyWebhookInvalid is the agent_action copy returned on every +// 400 from the notify_webhook validation gate (bad scheme, private/loopback +// IP, unresolvable hostname). Single sentence, names the rejection reason +// (private / loopback / not https), names the exact next action (supply a +// public https URL or omit the field), contains the full docs URL. +const AgentActionNotifyWebhookInvalid = "Tell the user the notify_webhook URL must be a public https:// endpoint — private/loopback IPs and http:// are rejected. Have them omit the field or use a public webhook URL — see https://instanode.dev/docs/deploy-webhooks." diff --git a/internal/handlers/deploy_webhook_notify_handler_test.go b/internal/handlers/deploy_webhook_notify_handler_test.go new file mode 100644 index 00000000..7235cd3b --- /dev/null +++ b/internal/handlers/deploy_webhook_notify_handler_test.go @@ -0,0 +1,349 @@ +package handlers_test + +// deploy_webhook_notify_handler_test.go — Black-box tests for the notify_webhook +// field on POST /deploy/new (migration 026). +// +// Four scenarios from the brief: +// 1. Valid https URL → 202, notify_state='pending' +// 2. Field absent → 202, notify_state='unset' (backward compat) +// 3. http:// (not https) → 400 + agent_action +// 4. Private IP literal → 400 + agent_action (SSRF gate) +// +// Plus one round-trip test that the secret is encrypted at rest (we read +// back from the DB directly and assert the stored ciphertext is not the +// plaintext we sent). + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// stubPublicResolver swaps the package-level DNS resolver so the SSRF +// gate sees the supplied IPs for every hostname. Returns a restorer +// the caller defers. Used by every test in this file that needs a +// hostname (not an IP literal) to pass the gate without doing real DNS. +func stubPublicResolver(t *testing.T, ips ...string) func() { + t.Helper() + parsed := make([]net.IP, 0, len(ips)) + for _, s := range ips { + ip := net.ParseIP(s) + require.NotNil(t, ip, "stubPublicResolver: %q is not a valid IP literal", s) + parsed = append(parsed, ip) + } + return handlers.SetNotifyWebhookResolverForTest(func(host string) ([]net.IP, error) { + return parsed, nil + }) +} + +// notifyDeployBody is a multipart builder with the notify_webhook fields. The +// tarball is a small fake — only the build path reads it, and we don't run +// against a real k8s here. +func notifyDeployBody(t *testing.T, fields map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("tarball", "app.tar.gz") + require.NoError(t, err) + _, err = fw.Write([]byte("fake-tarball-bytes")) + require.NoError(t, err) + for k, v := range fields { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +// TestDeployNew_NotifyWebhookValid_StoredPending guards scenario 1: +// a valid https URL must be persisted and notify_state must transition +// from the column default ('unset') to 'pending'. +// +// We assert against the DB row directly because the JSON response shape +// could swallow an extra field — the source of truth is the column. +func TestDeployNew_NotifyWebhookValid_StoredPending(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + defer stubPublicResolver(t, "8.8.8.8")() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "11111111-1111-1111-1111-111111111111", teamID, "notify@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := notifyDeployBody(t, map[string]string{ + "port": "8080", + "notify_webhook": "https://hooks.example.com/deploy", + }) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.26.0.1") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusAccepted, resp.StatusCode, + "valid https notify_webhook must be accepted; body: %s", string(bodyBytes)) + + // Decode the JSON response to grab the app_id, then verify the DB row + // directly — the persisted state is the source of truth. + var created struct { + Item struct { + AppID string `json:"app_id"` + NotifyWebhook string `json:"notify_webhook"` + NotifyState string `json:"notify_state"` + } `json:"item"` + } + require.NoError(t, json.Unmarshal(bodyBytes, &created)) + assert.Equal(t, "https://hooks.example.com/deploy", created.Item.NotifyWebhook, + "response must echo back the supplied URL") + assert.Equal(t, "pending", created.Item.NotifyState, + "notify_state must be 'pending' once a URL is supplied — the worker scan keys on it") + + // Round-trip via the DB (the worker scan reads this column, not the JSON). + var dbURL, dbState string + var dbAttempts int + err = db.QueryRowContext(context.Background(), + `SELECT notify_webhook, notify_state, notify_attempts FROM deployments WHERE app_id = $1`, + created.Item.AppID, + ).Scan(&dbURL, &dbState, &dbAttempts) + require.NoError(t, err) + assert.Equal(t, "https://hooks.example.com/deploy", dbURL) + assert.Equal(t, "pending", dbState) + assert.Equal(t, 0, dbAttempts, "fresh row must start at zero attempts") +} + +// TestDeployNew_NotifyWebhookAbsent_StaysUnset guards scenario 2: +// the column default ('unset') is what existing callers see when they +// don't pass the field. This is the backward-compatibility test. +func TestDeployNew_NotifyWebhookAbsent_StaysUnset(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "22222222-2222-2222-2222-222222222222", teamID, "no-webhook@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := notifyDeployBody(t, map[string]string{"port": "8080"}) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.26.0.2") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusAccepted, resp.StatusCode, + "deploy without notify_webhook must still succeed; body: %s", string(bodyBytes)) + + var created struct { + Item struct { + AppID string `json:"app_id"` + NotifyWebhook string `json:"notify_webhook"` + NotifyState string `json:"notify_state"` + } `json:"item"` + } + require.NoError(t, json.Unmarshal(bodyBytes, &created)) + assert.Empty(t, created.Item.NotifyWebhook, + "notify_webhook must be empty when not supplied") + assert.Equal(t, "unset", created.Item.NotifyState, + "notify_state must stay at column default 'unset' when no webhook is supplied") + + var dbState string + err = db.QueryRowContext(context.Background(), + `SELECT notify_state FROM deployments WHERE app_id = $1`, + created.Item.AppID, + ).Scan(&dbState) + require.NoError(t, err) + assert.Equal(t, "unset", dbState) +} + +// TestDeployNew_NotifyWebhookHTTP_Rejects guards scenario 3: plain http +// is rejected with 400 + the agent_action so the worker never POSTs over +// cleartext. +func TestDeployNew_NotifyWebhookHTTP_Rejects(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + defer stubPublicResolver(t, "8.8.8.8")() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "33333333-3333-3333-3333-333333333333", teamID, "http@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := notifyDeployBody(t, map[string]string{ + "port": "8080", + "notify_webhook": "http://hooks.example.com/deploy", + }) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.26.0.3") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "http:// notify_webhook must return 400") + + var errBody struct { + OK bool `json:"ok"` + Error string `json:"error"` + Message string `json:"message"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.False(t, errBody.OK) + assert.Equal(t, "invalid_notify_webhook", errBody.Error) + assert.Contains(t, errBody.Message, "https", + "message must name https so the agent knows the fix") + assert.NotEmpty(t, errBody.AgentAction, + "agent_action must be populated so the LLM has copy to relay") + assert.Contains(t, errBody.AgentAction, "https://instanode.dev/", + "agent_action must contain the docs URL") +} + +// TestDeployNew_NotifyWebhookPrivateIP_Rejects guards scenario 4: a private +// IP literal in the URL is rejected as SSRF — this is the gate that stops +// an attacker from pointing the platform's egress at 169.254.169.254 +// (cloud metadata) or 10.0.0.5 (internal services). +func TestDeployNew_NotifyWebhookPrivateIP_Rejects(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "44444444-4444-4444-4444-444444444444", teamID, "ssrf@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + // Each of these MUST be rejected — they are the classic SSRF targets. + cases := []string{ + "https://127.0.0.1/webhook", // loopback + "https://10.0.0.5/webhook", // RFC1918 + "https://192.168.1.1/webhook", // RFC1918 + "https://localhost/webhook", // literal name shortcut + } + for i, raw := range cases { + t.Run(raw, func(t *testing.T) { + body, ct := notifyDeployBody(t, map[string]string{ + "port": "8080", + "notify_webhook": raw, + }) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + // Unique source IP per case so the rate-limit fingerprint + // doesn't lump all four into the same /24 bucket. + req.Header.Set("X-Forwarded-For", + "10.26.0."+string(rune('a'+i))) // placeholder; overwritten below + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "SSRF-target %s must return 400", raw) + + var errBody struct { + OK bool `json:"ok"` + Error string `json:"error"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.Equal(t, "invalid_notify_webhook", errBody.Error) + assert.NotEmpty(t, errBody.AgentAction) + }) + } +} + +// TestDeployNew_NotifyWebhookSecret_EncryptedAtRest guards the AES +// requirement: the plaintext secret MUST NOT land in the deployments row. +// We sent a unique sentinel value; if it appears in the column we'd fail. +func TestDeployNew_NotifyWebhookSecret_EncryptedAtRest(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + defer stubPublicResolver(t, "8.8.8.8")() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "55555555-5555-5555-5555-555555555555", teamID, "secret@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + const plaintextSecret = "SENTINEL_PLAINTEXT_aabbccdd_DO_NOT_PERSIST" + body, ct := notifyDeployBody(t, map[string]string{ + "port": "8080", + "notify_webhook": "https://hooks.example.com/deploy", + "notify_webhook_secret": plaintextSecret, + }) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.26.0.5") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusAccepted, resp.StatusCode, + "valid deploy with secret must be accepted; body: %s", string(bodyBytes)) + + var created struct { + Item struct { + AppID string `json:"app_id"` + NotifySecretSet bool `json:"notify_secret_set"` + } `json:"item"` + } + require.NoError(t, json.Unmarshal(bodyBytes, &created)) + assert.True(t, created.Item.NotifySecretSet, + "notify_secret_set must be true when a secret was supplied") + + var dbSecret string + err = db.QueryRowContext(context.Background(), + `SELECT notify_webhook_secret FROM deployments WHERE app_id = $1`, + created.Item.AppID, + ).Scan(&dbSecret) + require.NoError(t, err) + assert.NotEmpty(t, dbSecret, "secret column must hold ciphertext, not be empty") + assert.NotEqual(t, plaintextSecret, dbSecret, + "plaintext secret MUST NOT appear in the column — that's the AES requirement") + assert.NotContains(t, dbSecret, "SENTINEL_PLAINTEXT", + "no sub-string of the plaintext can leak through into storage") + + // The JSON response also must not include the plaintext or the + // ciphertext (only the boolean indicator). + assert.NotContains(t, string(bodyBytes), plaintextSecret, + "response body must not echo the plaintext secret") +} diff --git a/internal/handlers/deploy_webhook_notify_test.go b/internal/handlers/deploy_webhook_notify_test.go new file mode 100644 index 00000000..f71c38bd --- /dev/null +++ b/internal/handlers/deploy_webhook_notify_test.go @@ -0,0 +1,207 @@ +package handlers + +// deploy_webhook_notify_test.go — Unit tests for the SSRF / scheme gate in +// validateNotifyWebhookURL. These live in package handlers (white-box) so +// they can swap out notifyWebhookResolver — production code does real DNS, +// tests inject a deterministic resolver per-table-case. +// +// The black-box end-to-end tests (handler accepts/rejects, persisted state) +// live in deploy_webhook_notify_handler_test.go (package handlers_test). + +import ( + "errors" + "net" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubResolver returns the supplied IPs for any hostname. Used to bypass +// real DNS in tests so we can exercise the IP-classification branches +// without depending on the world. +func stubResolver(ips ...string) func(string) ([]net.IP, error) { + parsed := make([]net.IP, 0, len(ips)) + for _, s := range ips { + parsed = append(parsed, net.ParseIP(s)) + } + return func(host string) ([]net.IP, error) { + return parsed, nil + } +} + +// errResolver simulates DNS failure for the unresolvable-hostname branch. +func errResolver() func(string) ([]net.IP, error) { + return func(host string) ([]net.IP, error) { + return nil, errors.New("no such host") + } +} + +// restoreResolver swaps the package-level resolver back at the end of a +// test. Tests run in parallel within a package so a leaky resolver would +// poison later cases — Cleanup keeps the swap scoped. +func restoreResolver(t *testing.T, replacement func(string) ([]net.IP, error)) { + t.Helper() + prev := notifyWebhookResolver + notifyWebhookResolver = replacement + t.Cleanup(func() { notifyWebhookResolver = prev }) +} + +// TestValidateNotifyWebhookURL_HTTPSPublic accepts the happy path: an +// https URL whose hostname resolves to a public IP. +func TestValidateNotifyWebhookURL_HTTPSPublic(t *testing.T) { + restoreResolver(t, stubResolver("8.8.8.8")) + + err := validateNotifyWebhookURL("https://hooks.example.com/webhook") + assert.NoError(t, err, "https URL with public-IP resolution must be accepted") +} + +// TestValidateNotifyWebhookURL_RejectsHTTP guards the scheme gate. +// Plain http is rejected so the worker never POSTs over cleartext. +func TestValidateNotifyWebhookURL_RejectsHTTP(t *testing.T) { + restoreResolver(t, stubResolver("8.8.8.8")) + + err := validateNotifyWebhookURL("http://hooks.example.com/webhook") + require.Error(t, err, "http:// must be rejected") + assert.Contains(t, err.Error(), "https", + "error must name https as the required scheme so the user knows the fix") +} + +// TestValidateNotifyWebhookURL_RejectsLocalhost guards the literal-hostname +// shortcut. "localhost" is rejected before any DNS lookup so /etc/hosts +// tricks can't sneak past. +func TestValidateNotifyWebhookURL_RejectsLocalhost(t *testing.T) { + // Resolver wouldn't even be called for "localhost" because the literal + // check fires first — but install a stub anyway so an accidental + // resolver call wouldn't trigger real DNS. + restoreResolver(t, stubResolver("8.8.8.8")) + + cases := []string{ + "https://localhost/webhook", + "https://localhost:8080/webhook", + "https://LOCALHOST/webhook", // case-insensitive + "https://app.localhost/webhook", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateNotifyWebhookURL(raw) + require.Error(t, err, "localhost variants must be rejected") + }) + } +} + +// TestValidateNotifyWebhookURL_RejectsPrivateIPLiteral guards the case +// where the URL embeds a private IP literal directly. No DNS needed. +func TestValidateNotifyWebhookURL_RejectsPrivateIPLiteral(t *testing.T) { + restoreResolver(t, stubResolver("8.8.8.8")) + + cases := []string{ + "https://127.0.0.1/webhook", // loopback + "https://10.0.0.5/webhook", // RFC1918 10/8 + "https://172.16.0.1/webhook", // RFC1918 172.16/12 + "https://192.168.1.1/webhook", // RFC1918 192.168/16 + "https://169.254.169.254/metadata", // cloud metadata (link-local) + "https://100.64.0.1/webhook", // CGNAT + "https://0.0.0.0/webhook", // unspecified + "https://[::1]/webhook", // IPv6 loopback + "https://[fe80::1]/webhook", // IPv6 link-local + "https://[fc00::1]/webhook", // IPv6 unique-local + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateNotifyWebhookURL(raw) + assert.Error(t, err, + "%s must be rejected as a blocked IP literal", raw) + if err != nil { + assert.True(t, strings.Contains(err.Error(), "blocked") || + strings.Contains(err.Error(), "private") || + strings.Contains(err.Error(), "loopback") || + strings.Contains(err.Error(), "publicly routable"), + "error must explain the rejection class: %v", err) + } + }) + } +} + +// TestValidateNotifyWebhookURL_RejectsHostnameResolvingPrivate guards the +// mixed-record SSRF dodge: an attacker controls DNS and points +// hooks.evil.com → [8.8.8.8, 10.0.0.5]. We must reject if ANY resolved +// IP is in a blocked range. +func TestValidateNotifyWebhookURL_RejectsHostnameResolvingPrivate(t *testing.T) { + restoreResolver(t, stubResolver("8.8.8.8", "10.0.0.5")) + + err := validateNotifyWebhookURL("https://hooks.evil.com/webhook") + require.Error(t, err, + "hostname resolving to mix of public+private IPs must be rejected") +} + +// TestValidateNotifyWebhookURL_RejectsUnresolvable guards the DNS-failure +// branch — a typo or non-existent hostname surfaces as a 400 (don't pretend +// the URL is fine if we can't even resolve it). +func TestValidateNotifyWebhookURL_RejectsUnresolvable(t *testing.T) { + restoreResolver(t, errResolver()) + + err := validateNotifyWebhookURL("https://does-not-exist.invalid./webhook") + require.Error(t, err, "unresolvable hostname must be rejected") +} + +// TestIsBlockedIP_CoversFullCIDRSet exercises isBlockedIP directly with the +// canonical representatives of each blocked range. This is the granular +// safety net under validateNotifyWebhookURL. +func TestIsBlockedIP_CoversFullCIDRSet(t *testing.T) { + cases := map[string]bool{ + // Blocked + "127.0.0.1": true, + "127.255.255.254": true, + "10.0.0.1": true, + "172.16.0.1": true, + "172.31.255.254": true, + "192.168.0.1": true, + "169.254.169.254": true, // AWS/GCP metadata + "100.64.0.1": true, // CGNAT + "100.127.255.254": true, // CGNAT upper + "224.0.0.1": true, // multicast + "255.255.255.255": true, // limited broadcast + "0.0.0.0": true, // unspecified + "::1": true, + "fe80::1": true, + "fc00::1": true, + "::": true, + + // Public — must NOT be blocked + "8.8.8.8": false, + "1.1.1.1": false, + "172.15.0.1": false, // just below RFC1918 + "172.32.0.1": false, // just above RFC1918 + "100.63.255.254": false, // just below CGNAT + "100.128.0.1": false, // just above CGNAT + "2001:4860:4860::8888": false, // Google IPv6 DNS + } + for ipStr, expected := range cases { + t.Run(ipStr, func(t *testing.T) { + ip := net.ParseIP(ipStr) + require.NotNil(t, ip, "test fixture %q must parse as IP", ipStr) + got := isBlockedIP(ip) + assert.Equal(t, expected, got, + "isBlockedIP(%q): want %v, got %v", ipStr, expected, got) + }) + } +} + +// TestValidateNotifyWebhookURL_RejectsMalformed guards the url.Parse failure +// branch. An obviously malformed URL surfaces as a clear 400. +func TestValidateNotifyWebhookURL_RejectsMalformed(t *testing.T) { + restoreResolver(t, stubResolver("8.8.8.8")) + cases := []string{ + "not a url", + "://no-scheme", + "https://", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateNotifyWebhookURL(raw) + require.Error(t, err, "malformed URL %q must be rejected", raw) + }) + } +} diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index c8206654..c09bf276 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -276,7 +276,7 @@ const openAPISpec = `{ "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DeployRequest" } } } }, "responses": { "202": { "description": "Deployment accepted, building", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } }, - "400": { "description": "Bad request — invalid env_vars JSON, invalid_resource_binding (resource_bindings value is not a UUID or family:), private_deploy_requires_allowed_ips (private=true with no IPs), invalid_allowed_ip (bad CIDR/IP literal), or too_many_allowed_ips (>32 entries)" }, + "400": { "description": "Bad request — invalid env_vars JSON, invalid_resource_binding (resource_bindings value is not a UUID or family:), private_deploy_requires_allowed_ips (private=true with no IPs), invalid_allowed_ip (bad CIDR/IP literal), too_many_allowed_ips (>32 entries), or invalid_notify_webhook (URL is not https, unresolvable, or resolves to a private/loopback/link-local IP)" }, "401": { "description": "Unauthorized" }, "402": { "description": "deployment_limit_reached OR private_deploy_requires_pro — hobby/anonymous/free trying to set private=true. agent_action points to https://instanode.dev/pricing." }, "403": { "description": "Blocked by team env_policy, OR resource_binding_forbidden (binding references a resource owned by a different team)" }, @@ -1799,7 +1799,9 @@ const openAPISpec = `{ "env_vars": { "type": "string", "description": "Optional JSON object of env vars to inject into the deployed pod on the FIRST build — e.g. '{\"DATABASE_URL\":\"postgres://...\",\"REDIS_URL\":\"redis://...\"}'. Avoids the (POST /deploy/new) → (PATCH /env) → (POST /redeploy) round-trip pattern. Values may use 'vault://KEY' refs which resolve at deploy time. Keys starting with underscore are reserved and ignored." }, "resource_bindings": { "type": "string", "description": "Optional JSON object mapping env-var-name to a resource reference. Values can be either 'family:' (resolved at submit time to the family member matching the deploy's env — one manifest works across all envs) or a raw resource-token UUID (legacy path; resolves to that specific resource regardless of env). Resolved values are merged into env_vars, with explicit env_vars taking precedence on key collision. Example: '{\"DATABASE_URL\":\"family:7a3f2c91-...\",\"REDIS_URL\":\"family:9bd5f3e0-...\"}'." }, "private": { "type": "string", "description": "Optional flag (\"true\" / \"1\" / \"yes\") that turns this into a private deploy. When set, the resulting Ingress carries an nginx whitelist-source-range annotation built from allowed_ips. Pro / Team / Growth only — hobby/anonymous/free return 402 with agent_action: \"Tell the user private deploys require Pro tier. Upgrade at https://instanode.dev/pricing — takes 30 seconds.\"" }, - "allowed_ips": { "type": "string", "description": "Comma-separated list of CIDRs or IP literals (e.g. \"1.2.3.4,10.0.0.0/8,2001:db8::/32\"). Required when private=true; max 32 entries. Each entry is validated via Go's net.ParseCIDR / net.ParseIP — invalid entries surface in the 400 message so an agent can fix the literal that broke. Larger allowlists belong in CF Access or a real VPN, not an nginx annotation." } + "allowed_ips": { "type": "string", "description": "Comma-separated list of CIDRs or IP literals (e.g. \"1.2.3.4,10.0.0.0/8,2001:db8::/32\"). Required when private=true; max 32 entries. Each entry is validated via Go's net.ParseCIDR / net.ParseIP — invalid entries surface in the 400 message so an agent can fix the literal that broke. Larger allowlists belong in CF Access or a real VPN, not an nginx annotation." }, + "notify_webhook": { "type": "string", "description": "Optional https:// URL fired by POST when the deploy reaches a terminal state (status='healthy' or 'failed'). Lets callers subscribe instead of polling GET /deploy/:id. Rejected with 400 + agent_action if the URL is not https, the hostname is unresolvable, or resolves to a private/loopback/link-local/CGNAT IP (SSRF protection). Payload shape: { event: 'deploy.healthy' | 'deploy.failed', deploy_id, app_id, url, commit_id, build_time, duration_s, error_message? }. 2xx → notify_state='sent'; 4xx → 'failed' (no retry — user URL is broken); 5xx/network → up to 3 retries, then 'failed'." }, + "notify_webhook_secret": { "type": "string", "description": "Optional HMAC-SHA256 signing key. When set, every dispatch includes an X-InstaNode-Signature: sha256= header. Stored AES-256-GCM encrypted; plaintext never leaves the request. Omit to dispatch without a signature header." } }, "required": ["tarball"] }, @@ -1820,6 +1822,10 @@ const openAPISpec = `{ "port": { "type": "integer" }, "private": { "type": "boolean", "description": "True when the Ingress is locked down via nginx whitelist-source-range. Pro / Team / Growth feature." }, "allowed_ips": { "type": "array", "items": { "type": "string" }, "description": "CIDRs / IPs whitelisted on the Ingress when private=true. Empty array on a public deploy." }, + "notify_webhook": { "type": "string", "description": "Echoed-back webhook URL when set on POST /deploy/new. Empty string when no webhook was configured for this deployment." }, + "notify_state": { "type": "string", "enum": ["unset", "pending", "sent", "failed"], "description": "Lifecycle of the deploy-notify webhook. 'unset' = no URL configured. 'pending' = URL configured, awaiting terminal state (or worker dispatch). 'sent' = 2xx received. 'failed' = 4xx received OR 5xx/network exhausted retries." }, + "notify_attempts": { "type": "integer", "description": "Count of dispatch attempts made by the worker. Present only when notify_webhook is set. 5xx/network errors retry up to 3 times; 4xx is permanent." }, + "notify_secret_set": { "type": "boolean", "description": "True when an HMAC signing secret was supplied at create time. Present only when notify_webhook is set. The plaintext secret is never returned." }, "team_id": { "type": "string", "format": "uuid" } } }, diff --git a/internal/models/deployment.go b/internal/models/deployment.go index cfc59c0c..c9ba2560 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -18,36 +18,59 @@ import ( // nginx.ingress.kubernetes.io/whitelist-source-range annotation. AllowedIPs // is stored as a comma-joined TEXT column (not JSONB) — keeps the model's // scalar-friendly shape and matches the Ingress annotation format byte-for-byte. +// +// NotifyWebhook / NotifyWebhookSecret / NotifyState / NotifyAttempts back the +// deploy-webhook-notify feature (migration 026). When NotifyWebhook is set, +// the worker POSTs to it once the deploy reaches a terminal state — healthy +// or failed. NotifyWebhookSecret (when supplied) is the HMAC-SHA256 signing +// key for the X-InstaNode-Signature header; it is AES-256-GCM encrypted at +// rest using the platform AES_KEY (same shape as resources.connection_url). +// The model surfaces the ENCRYPTED form — the worker decrypts at dispatch +// time so plaintext never lands in the deployments row. type Deployment struct { - ID uuid.UUID - TeamID uuid.UUID - ResourceID uuid.NullUUID - AppID string - ProviderID string // k8s Deployment name, e.g. "app-{app_id}" - Status string // building | deploying | healthy | failed | stopped - AppURL string - EnvVars map[string]string - Port int - Tier string - Env string // dev | staging | production | ; defaults to "production" - Private bool - AllowedIPs []string // parsed from the comma-joined `allowed_ips` column - ErrorMessage string - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + TeamID uuid.UUID + ResourceID uuid.NullUUID + AppID string + ProviderID string // k8s Deployment name, e.g. "app-{app_id}" + Status string // building | deploying | healthy | failed | stopped + AppURL string + EnvVars map[string]string + Port int + Tier string + Env string // dev | staging | production | ; defaults to "production" + Private bool + AllowedIPs []string // parsed from the comma-joined `allowed_ips` column + NotifyWebhook string // user-supplied https:// URL; empty when unset + NotifyWebhookSecret string // AES-256-GCM ciphertext of the HMAC key; empty when unset + NotifyState string // 'unset' | 'pending' | 'sent' | 'failed' + NotifyAttempts int // dispatch retry counter (worker bumps on 5xx/network) + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time } // CreateDeploymentParams holds fields for inserting a new deployment row. +// +// NotifyWebhook (when non-empty) must already be a validated https:// URL +// pointing at a publicly routable hostname (SSRF-checked by the handler +// before this struct is constructed). NotifyWebhookSecret (when non-empty) +// must already be AES-256-GCM ciphertext — this layer does no crypto. +// When NotifyWebhook is empty, NotifyState defaults to 'unset' at the DB +// layer; when non-empty, the INSERT sets it to 'pending' so the worker +// scan picks it up the moment the deploy reaches a terminal state. type CreateDeploymentParams struct { - TeamID uuid.UUID - ResourceID *uuid.UUID - AppID string - Port int - Tier string - Env string // empty string is normalised to EnvProduction - EnvVars map[string]string - Private bool - AllowedIPs []string // each entry must already be a valid IP or CIDR + TeamID uuid.UUID + ResourceID *uuid.UUID + AppID string + Port int + Tier string + Env string // empty string is normalised to EnvProduction + EnvVars map[string]string + Private bool + AllowedIPs []string // each entry must already be a valid IP or CIDR + NotifyWebhook string // empty = no webhook; non-empty = validated https URL + NotifyWebhookSecret string // empty = no HMAC; non-empty = AES ciphertext } // ErrDeploymentNotFound is returned when a deployment lookup yields no rows. @@ -60,8 +83,12 @@ func (e *ErrDeploymentNotFound) Error() string { } // deploymentColumns is the canonical column list shared by all deployment SELECTs. +// notify_webhook / notify_webhook_secret / notify_state / notify_attempts +// (migration 026) are appended at the end so existing column-order assumptions +// in this file's scanDeployment continue to compile-fail loudly on drift. const deploymentColumns = `id, team_id, resource_id, app_id, provider_id, status, app_url, - env_vars, port, tier, env, private, allowed_ips, error_message, created_at, updated_at` + env_vars, port, tier, env, private, allowed_ips, error_message, created_at, updated_at, + notify_webhook, notify_webhook_secret, notify_state, notify_attempts` // scanDeployment reads a single deployments row into a Deployment struct. // env_vars is stored as JSONB; error_message, provider_id, and app_url are nullable. @@ -74,6 +101,10 @@ func scanDeployment(row interface { var providerID, appURL, errorMessage sql.NullString var resourceID uuid.NullUUID var allowedIPsRaw string + // migration 026: notify_webhook / notify_webhook_secret are nullable + // (legacy rows have NULL); notify_state defaults to 'unset' (NOT NULL) + // and notify_attempts defaults to 0 (NOT NULL). + var notifyWebhook, notifyWebhookSecret sql.NullString if err := row.Scan( &d.ID, &d.TeamID, &resourceID, &d.AppID, @@ -82,6 +113,7 @@ func scanDeployment(row interface { &d.Private, &allowedIPsRaw, &errorMessage, &d.CreatedAt, &d.UpdatedAt, + ¬ifyWebhook, ¬ifyWebhookSecret, &d.NotifyState, &d.NotifyAttempts, ); err != nil { return nil, err } @@ -91,6 +123,8 @@ func scanDeployment(row interface { d.AppURL = appURL.String d.ErrorMessage = errorMessage.String d.AllowedIPs = splitAllowedIPs(allowedIPsRaw) + d.NotifyWebhook = notifyWebhook.String + d.NotifyWebhookSecret = notifyWebhookSecret.String if len(envVarsRaw) > 0 { if err := json.Unmarshal(envVarsRaw, &d.EnvVars); err != nil { @@ -162,13 +196,30 @@ func CreateDeployment(ctx context.Context, db *sql.DB, p CreateDeploymentParams) // the form the nginx whitelist-source-range annotation already requires. allowedIPs := JoinAllowedIPs(p.AllowedIPs) + // notify_state lifecycle (migration 026): + // no URL supplied → 'unset' (column default, but explicit here so + // the contract is visible in the query) + // URL supplied → 'pending' (worker scan picks it up the moment + // the deploy reaches a terminal state) + notifyState := "unset" + var notifyWebhook, notifyWebhookSecret interface{} + if p.NotifyWebhook != "" { + notifyState = "pending" + notifyWebhook = p.NotifyWebhook + if p.NotifyWebhookSecret != "" { + notifyWebhookSecret = p.NotifyWebhookSecret + } + } + row := db.QueryRowContext(ctx, ` INSERT INTO deployments - (team_id, resource_id, app_id, port, tier, env, env_vars, private, allowed_ips) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (team_id, resource_id, app_id, port, tier, env, env_vars, private, allowed_ips, + notify_webhook, notify_webhook_secret, notify_state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING `+deploymentColumns, p.TeamID, resourceID, p.AppID, port, p.Tier, env, envVarsJSON, - p.Private, allowedIPs) + p.Private, allowedIPs, + notifyWebhook, notifyWebhookSecret, notifyState) d, err := scanDeployment(row) if err != nil {