From 504ce485748525d448c18826fdacab24f7e08429 Mon Sep 17 00:00:00 2001 From: nedson202 Date: Mon, 28 Sep 2026 21:54:00 +0000 Subject: [PATCH] feat: deploy hooks for CI-triggered app updates Lets an external CI pipeline (GitHub Actions or anything that can POST) trigger the same pull-and-restart the dashboard's Update button runs, without Selfhostly ever knowing GitHub, or any Git host, exists. Backend: - New `app_deploy_hooks` table: named, per-app bearer tokens, sha256-hashed at rest (same scheme as join tokens), never recoverable after creation. - `POST/GET /api/apps/:id/deploy-hooks`, `DELETE .../deploy-hooks/:hookId` (session-authed CRUD) and `POST /api/apps/:id/deploy-trigger` (token-authed, rate-limited per app, mounted outside the session/node auth group since the token is its own credential). - The trigger route calls the exact same `UpdateAppContainersAsync` the manual Update button calls - a hook grants no capability a signed-in user didn't already have. - Extensible `source_kind` verifier registry for future trigger kinds beyond the current bearer-token-only `generic` kind. - Full multi-node support: the gateway routes by `node_id` like every other by-id route, is exempted from its JWT check (a deploy-hook token isn't a JWT), and `forwardDeployTriggerToLinkedNode` relays a trigger to a linked secondary while deliberately preserving `Authorization` (unlike the session-forwarding path, which strips it) since that header carries the request's only credential. - Audited (`app.deploy_hook.create/.revoke`, `app.deploy_trigger`); a trigger's actor reads `deploy-hook:` since there's no session. Frontend: - New "Deploy" tab: named hooks with last-triggered time/IP, reveal-once token, a copy-paste GitHub Actions step (reads the instance URL, app id and node id from repo variables rather than baking them into the workflow file), and a warning when the dashboard's own address looks unreachable from a hosted CI runner (localhost/LAN). Verified against real running `cmd/server`/`cmd/gateway` binaries and real Docker, including a real linked secondary over a live WebSocket link: create/list/trigger/revoke, rate limiting, cross-app token rejection, and the full CI-through-gateway-through-primary-through-link-to-secondary path with a genuine `docker compose pull`/`up`. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 1 + docs/design/app-deploy-hooks.md | 143 ++++++++++ internal/constants/constants.go | 21 +- internal/db/deploy_hooks.go | 124 +++++++++ internal/db/deploy_hooks_test.go | 161 +++++++++++ internal/db/models.go | 125 +++++---- internal/db/versioned.go | 17 ++ internal/domain/errors.go | 9 +- internal/domain/security.go | 33 +++ internal/gateway/auth.go | 7 + internal/gateway/auth_test.go | 2 + internal/http/app_deploy_hooks.go | 114 ++++++++ internal/http/app_deploy_hooks_test.go | 159 +++++++++++ internal/http/audit_actions.go | 3 + internal/http/node_link.go | 95 +++++-- internal/http/node_link_test.go | 129 ++++++++- internal/http/routes.go | 32 +++ internal/http/security.go | 22 ++ internal/http/server.go | 97 +++---- internal/service/deploy_hook_service.go | 109 ++++++++ .../app-details/components/AppTabContent.tsx | 3 + .../app-details/components/DeployHooksTab.tsx | 261 ++++++++++++++++++ web/src/features/app-details/index.tsx | 5 +- .../app-details/lib/deploy-hook-rules.ts | 30 ++ web/src/shared/lib/routes.ts | 4 +- web/src/shared/services/api/apps.ts | 45 +++ web/src/shared/types/api.ts | 12 + web/tests/deploy-hook-rules.test.ts | 44 +++ 28 files changed, 1671 insertions(+), 136 deletions(-) create mode 100644 docs/design/app-deploy-hooks.md create mode 100644 internal/db/deploy_hooks.go create mode 100644 internal/db/deploy_hooks_test.go create mode 100644 internal/http/app_deploy_hooks.go create mode 100644 internal/http/app_deploy_hooks_test.go create mode 100644 internal/service/deploy_hook_service.go create mode 100644 web/src/features/app-details/components/DeployHooksTab.tsx create mode 100644 web/src/features/app-details/lib/deploy-hook-rules.ts create mode 100644 web/tests/deploy-hook-rules.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6ccf31e8..09bea0b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ it applies. Load the ones relevant to the task. Never restate a rule here. | Security model, blocked compose configs, `SECURITY_MODE` | `docs/security/overview.md` and `internal/validation/` | | Deploy and restart | `docs/operations/install.md`, `docs/operations/operate.md`, `docs/reference/selfhostlyctl.md` | | Updating from the UI (signed releases, the updater container) | `docs/design/ui-updates.md` | +| CI-triggered app updates (deploy hooks) | `docs/design/app-deploy-hooks.md` | | Multi-node and gateway | `docs/operations/multi-node.md`, `docs/operations/gateway-deployment.md` | | UI decisions | `docs/design/ui.md` | | Missing backend features | `docs/design/backend-gaps.md` | diff --git a/docs/design/app-deploy-hooks.md b/docs/design/app-deploy-hooks.md new file mode 100644 index 00000000..92a7f4c8 --- /dev/null +++ b/docs/design/app-deploy-hooks.md @@ -0,0 +1,143 @@ +# Deploy hooks + +A deploy hook closes the loop between an app's own repository and Selfhostly, without Selfhostly ever knowing that +repository, or any Git host, exists. A hook is a per-app secret token; POSTing it to `/api/apps/:id/deploy-trigger` +runs the exact same pull-and-restart that clicking Update in the dashboard runs. Wire it into a CI pipeline's last +step (a GitHub Actions workflow, a GitLab job, a plain cron with curl, anything that can make an HTTPS request) and a +push to `main` reaches the running app with nobody clicking anything. + +This is deliberately not the same thing as [UI-driven updates](ui-updates.md), which update Selfhostly itself from a +signed release manifest. A deploy hook updates a user's own app, from whatever image its compose file already points +at. + +## Why no GitHub integration + +Earlier designs considered Selfhostly linking a GitHub repo, watching it, or receiving GitHub's own webhooks. +None of that is here. The trigger is a bearer token a pipeline presents; what the token authorizes is fixed +(`docker compose pull` + `up -d` for the one app it belongs to) and never depends on anything the caller says about +itself. This keeps the trust surface identical to the dashboard's own Update button: a hook grants no capability +a signed-in user did not already have, and Selfhostly does not need OAuth scopes, a GitHub App, or a registry API +client to make it work. + +The compose file's image reference does not change when a hook fires: `docker compose pull` only picks up a newer +digest under the tag the compose file already names. This only closes the loop for apps whose image is built to a +mutable tag CI keeps pushing to (`:latest`, `:main`, ...). A pinned, immutable tag needs its compose file edited by +hand, the same as it always did; deploy hooks do not rewrite compose content. + +## Data model + +`app_deploy_hooks` (`internal/db/deploy_hooks.go`): `id`, `app_id`, `name`, `source_kind`, `token_hash`, `created_at`, +`last_used_at`, `last_used_ip`. Several hooks can exist per app, each independently named, triggered and revoked, +the same as Vercel or Netlify's deploy hooks: one for a `main` branch pipeline and a separate one for a manual +re-trigger do not have to share a token or a revoke button. + +Only the hash is stored (`sha256`, the same scheme as join tokens in `internal/db/secure.go`). The plaintext token is +returned exactly once, in the response to creating the hook, and cannot be recovered afterwards; losing it means +rotating (revoke and make a new one). + +## Source kinds: the extensible part + +Every hook has a `source_kind`, today always `generic`: nothing beyond the token match is required. The verifier +registry in `internal/service/deploy_hook_service.go` is where a future kind that needs more would live, for +example one that also checks a caller's identity: + +```go +var deploySourceVerifiers = map[string]func(ctx context.Context, hook *db.DeployHook, callerIP string) error{ + // "github_actions": verifyGitHubActionsOIDC, +} +``` + +A kind with no entry gets the shared check only. Adding a kind is a new map entry and a new file, not a schema +change or a new route. A kind whose entire transport differs from "present a bearer token in a header" (an inbound +webhook with a signed body, say) gets its own route rather than being folded into this one, since dispatching +unrelated request shapes through a single handler is where "generic" stops being simple. + +## Security properties + +- **Same blast radius as the manual Update button.** The trigger route calls the identical + `AppService.UpdateAppContainersAsync` the dashboard's Update button calls. A hook cannot change a compose file, + read a secret, or act on any app but the one it was made for. +- **Scoped per app.** `ConsumeDeployHookToken` matches a token against one `app_id`; a token made for one app is + rejected outright against another, even if it were somehow guessed. +- **Rate limited per app**, not per caller IP - a CI runner's address is not a stable identity to key on, but an + app's own budget (`constants.DeployTriggerRateLimitAttempts` per `DeployTriggerRateLimitWindow`) still bounds how + hard its hooks can be brute-forced or misfired, from any source. +- **Independently revocable.** Revoking one hook never affects another hook on the same app. +- **Audited and attributed.** Every trigger, successful or not, is recorded (`app.deploy_trigger`); the actor reads + `deploy-hook:` rather than a user id, since there is no session on this request, and `last_used_at` / + `last_used_ip` are shown per hook in the dashboard so a hook firing from an unexpected place is visible without + reading the audit log. +- **No new inbound trust.** The route is mounted outside the session/node auth group (`internal/http/routes.go`, + alongside node registration) because it carries its own credential instead of a session, not because it skips + authentication - the token is checked on every request, same as a node's API key is checked on every node route. +- **Managing hooks allows node auth, deliberately.** Creating, listing and revoking hooks is reachable with a node's + own credentials, the same as `start`/`stop`/`update`/`delete` on that node's apps already are. This is not a gap: + a node's credentials already give it full control over apps hosted on it, so this adds no capability beyond what + it already has, and denying node auth here would break the only way it can be reached for an app on a linked + (tunnel) secondary - `forwardToLinkedNode` re-signs a forwarded request with the target's node credentials, since + the secondary has no way to see the original session. + +## API + +| Route | Auth | Purpose | +|---|---|---| +| `POST /api/apps/:id/deploy-hooks` | session | Create a hook, named. Returns the plaintext token once. | +| `GET /api/apps/:id/deploy-hooks` | session | List an app's hooks. Never returns a token. | +| `DELETE /api/apps/:id/deploy-hooks/:hookId` | session | Revoke one hook immediately. | +| `POST /api/apps/:id/deploy-trigger` | hook token (`Authorization: Bearer `) | Trigger the same job the Update button starts. 202 with `job_id`, or the id of an already-active job if one is running. | + +## Example: GitHub Actions + +The last step of a build-and-push job, after it pushes the app's image to its `:latest` tag: + +```yaml +- name: Trigger Selfhostly deploy hook + run: | + curl --fail --silent --show-error \ + --retry 3 --retry-delay 5 \ + -X POST "${{ vars.SELFHOSTLY_URL }}/api/apps/${{ vars.SELFHOSTLY_APP_ID }}/deploy-trigger?node_id=${{ vars.SELFHOSTLY_NODE_ID }}" \ + -H "Authorization: Bearer ${{ secrets.SELFHOSTLY_DEPLOY_TOKEN }}" +``` + +`SELFHOSTLY_URL`, `SELFHOSTLY_APP_ID` and `SELFHOSTLY_NODE_ID` are plain repo variables, deliberately not baked into +the workflow file: the same step then needs no edit if any of them ever changes (a new domain, a new tunnel, moving +the app to a different node), and nothing instance-specific ends up committed to the repo. `SELFHOSTLY_DEPLOY_TOKEN` +is the one-time token from the app's Deploy tab, stored as a repo secret. `--fail` turns a bad or revoked token into +a failed workflow step instead of a silent no-op; `--retry` covers a briefly-unreachable instance (a home-hosted +primary restarting for its own update, a network blip), not anything token-related. + +The dashboard's Deploy tab shows this exact step (unfilled) plus the three variable values ready to copy, and warns +when the address it was opened through is a loopback or private-network one (`localhost`, `192.168.x.x`, ...): that +address is real on the machine running the browser, but GitHub's hosted runners cannot reach it, only a self-hosted +runner on the same network could. Copying such an address into `SELFHOSTLY_URL` produces a workflow that fails +every time, not one that fails to update. + +## Multi-node routing + +`node_id` is required for the same reason every other by-id route in this API requires it +(`internal/gateway/router.go`): the gateway that fronts a multi-node install cannot know which node holds a given +app id on its own, so it routes purely on the query parameter, straight to that node's own address when it is +directly reachable or to the primary when it is a linked (tunnel) node - identical to how the dashboard's own calls +are routed. `/api/apps/:id/deploy-trigger` is also added to the gateway's auth-skip list +(`internal/gateway/auth.go`): a deploy hook's bearer token is not a JWT, and the gateway would otherwise try to +validate it as one and reject it before the backend ever saw it. + +For an app on a linked node, the primary does a second hop: `forwardDeployTriggerToLinkedNode` +(`internal/http/node_link.go`) relays the request down that node's connection, the same job `forwardToLinkedNode` +does for every session-authed by-id route - with one deliberate difference. `forwardToLinkedNode` strips +`Authorization` before forwarding, because for a session-authed request that header holds the *user's* credential, +which a linked node has no business seeing; the primary re-signs the forwarded request with the node's own +credentials instead. The deploy-trigger forward keeps `Authorization` exactly as it came in, because there it holds +the *deploy hook's* credential, the only one the request has, and it must reach the linked node's own token check +unchanged - node credentials are not involved on this route at all. + +## Not covered + +- Cryptographic image provenance (a cosign/sigstore signature, a SLSA attestation) is not checked before a pull. + Trust is the registry plus the digest `docker compose pull` resolves, the same level of trust the platform's own + images get in [UI-driven updates](ui-updates.md#trust). Verifying a signature would be a `source_kind` addition, + not a redesign. +- A hook cannot be scoped to a caller IP or CIDR range. The schema has room to add this later (a nullable column on + `app_deploy_hooks`, checked in a verifier) but nothing does today. +- Zero-touch app creation from a repository (declaring a brand-new app from a build with no app to point a hook at + yet) is a separate, larger feature and is not part of this one. diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 8b1000e1..3ec71936 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -289,6 +289,25 @@ const ( JoinTokenByteSize = 24 ) +// Deploy hooks are per-app, long-lived secrets an external pipeline presents to trigger a pull and +// restart without a session. Unlike join tokens they are not single-use: they live until revoked or +// rotated, so there is no TTL. +const ( + DeployHookTokenPrefix = "sfd_" + DeployHookTokenByteSize = 24 + // DeploySourceGeneric is the only trigger-source kind today: a hook that needs nothing beyond its + // bearer token. A future kind (e.g. one that also checks a caller's identity) adds its own value + // here and a verifier in internal/service, without changing the schema or the route. + DeploySourceGeneric = "generic" +) + +// Rate limiting for the deploy-trigger endpoint, keyed per app so one app's noisy or leaked hook +// cannot exhaust another app's budget. +const ( + DeployTriggerRateLimitAttempts = 20 + DeployTriggerRateLimitWindow = 5 * time.Minute +) + // Secrets at rest const ( SecretsCipherPrefix = "enc:v1:" @@ -312,7 +331,7 @@ const ( LabelComposeWorkingDir = "com.docker.compose.project.working_dir" // LabelComposeConfigFiles lists, comma separated, the compose files a project was started from LabelComposeConfigFiles = "com.docker.compose.project.config_files" - LabelManaged = "com.selfhostly.managed" + LabelManaged = "com.selfhostly.managed" ) // Cloudflare Access verification diff --git a/internal/db/deploy_hooks.go b/internal/db/deploy_hooks.go new file mode 100644 index 00000000..841df702 --- /dev/null +++ b/internal/db/deploy_hooks.go @@ -0,0 +1,124 @@ +package db + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "time" + + "github.com/google/uuid" + "github.com/selfhostly/internal/constants" +) + +// ErrDeployHookNotFound is returned by RevokeDeployHook when the id does not belong to the app. +var ErrDeployHookNotFound = errors.New("deploy hook not found") + +// CreateDeployHook issues a new deploy hook for an app. Only its hash is stored, so the caller must +// keep the returned plaintext token: it cannot be recovered later, the same as a join token. +func (db *DB) CreateDeployHook(appID, name, sourceKind string) (plain string, hook *DeployHook, err error) { + raw := make([]byte, constants.DeployHookTokenByteSize) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + plain = constants.DeployHookTokenPrefix + base64.RawURLEncoding.EncodeToString(raw) + + hook = &DeployHook{ + ID: uuid.New().String(), + AppID: appID, + Name: name, + SourceKind: sourceKind, + CreatedAt: time.Now().UTC(), + } + _, err = db.Exec(`INSERT INTO app_deploy_hooks (id, app_id, name, source_kind, token_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + hook.ID, hook.AppID, hook.Name, hook.SourceKind, hashToken(plain), hook.CreatedAt) + if err != nil { + return "", nil, err + } + return plain, hook, nil +} + +// ListDeployHooks returns an app's hooks, newest first, never nil. Token hashes are still scanned +// (they are part of the row) but the type never marshals them to JSON. +func (db *DB) ListDeployHooks(appID string) ([]*DeployHook, error) { + rows, err := db.Query(`SELECT id, app_id, name, source_kind, token_hash, created_at, last_used_at, COALESCE(last_used_ip, '') + FROM app_deploy_hooks WHERE app_id = ? ORDER BY created_at DESC`, appID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []*DeployHook{} + for rows.Next() { + var h DeployHook + if err := rows.Scan(&h.ID, &h.AppID, &h.Name, &h.SourceKind, &h.TokenHash, &h.CreatedAt, &h.LastUsedAt, &h.LastUsedIP); err != nil { + return nil, err + } + out = append(out, &h) + } + return out, rows.Err() +} + +// ConsumeDeployHookToken looks up the hook a presented token belongs to, scoped to one app so a +// token for a different app never matches, and records the calling address. It reports (nil, nil) +// for a token that does not match any hook on this app: not found is not an error here, since a +// wrong or revoked token is an expected caller mistake, not a database fault. +// +// The update and the read run in one transaction, so a concurrent RevokeDeployHook cannot delete the +// row between the two: without that, a revoke landing in that gap would make this record a use +// against a row that then reads back as gone, rejecting a token that was still valid when presented. +func (db *DB) ConsumeDeployHookToken(appID, presented, callerIP string) (*DeployHook, error) { + if presented == "" { + return nil, nil + } + hash := hashToken(presented) + now := time.Now().UTC() + + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() // no-op once committed + + res, err := tx.Exec(`UPDATE app_deploy_hooks SET last_used_at = ?, last_used_ip = ? + WHERE app_id = ? AND token_hash = ?`, now, callerIP, appID, hash) + if err != nil { + return nil, err + } + if n, err := res.RowsAffected(); err != nil || n == 0 { + return nil, err + } + + var h DeployHook + err = tx.QueryRow(`SELECT id, app_id, name, source_kind, token_hash, created_at, last_used_at, COALESCE(last_used_ip, '') + FROM app_deploy_hooks WHERE app_id = ? AND token_hash = ?`, appID, hash). + Scan(&h.ID, &h.AppID, &h.Name, &h.SourceKind, &h.TokenHash, &h.CreatedAt, &h.LastUsedAt, &h.LastUsedIP) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &h, nil +} + +// RevokeDeployHook deletes one hook. It stops working immediately; other hooks on the app, if any, +// are unaffected. Returns ErrDeployHookNotFound when the id does not belong to appID. +func (db *DB) RevokeDeployHook(appID, hookID string) error { + res, err := db.Exec(`DELETE FROM app_deploy_hooks WHERE id = ? AND app_id = ?`, hookID, appID) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrDeployHookNotFound + } + return nil +} diff --git a/internal/db/deploy_hooks_test.go b/internal/db/deploy_hooks_test.go new file mode 100644 index 00000000..cf8e45bf --- /dev/null +++ b/internal/db/deploy_hooks_test.go @@ -0,0 +1,161 @@ +package db + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +// newTestApp creates an app with a unique name, since apps.name is unique and several tests in +// this file need more than one app. +func newTestApp(t *testing.T, d *DB) *App { + t.Helper() + app := NewApp("demo-"+uuid.New().String(), "", "services: {}") + if err := d.CreateApp(app); err != nil { + t.Fatal(err) + } + return app +} + +func TestCreateDeployHookIsStoredHashedAndScopedToItsApp(t *testing.T) { + d, _ := newTestDB(t) + appA := newTestApp(t, d) + appB := newTestApp(t, d) + + plain, hook, err := d.CreateDeployHook(appA.ID, "GitHub Actions", "generic") + if err != nil || !strings.HasPrefix(plain, "sfd_") { + t.Fatal(err, plain) + } + if hook.AppID != appA.ID || hook.Name != "GitHub Actions" || hook.SourceKind != "generic" { + t.Fatalf("unexpected hook: %+v", hook) + } + + var stored string + if err := d.QueryRow(`SELECT token_hash FROM app_deploy_hooks WHERE id = ?`, hook.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + if strings.Contains(stored, plain) { + t.Fatal("token must be stored hashed, not in plaintext") + } + + // A correct token only matches the app it was made for. + got, err := d.ConsumeDeployHookToken(appA.ID, plain, "1.2.3.4") + if err != nil || got == nil || got.ID != hook.ID { + t.Fatalf("expected a match on its own app: %v %+v", err, got) + } + got, err = d.ConsumeDeployHookToken(appB.ID, plain, "1.2.3.4") + if err != nil || got != nil { + t.Fatalf("a token must not match a different app: %v %+v", err, got) + } +} + +func TestConsumeDeployHookTokenRecordsLastUse(t *testing.T) { + d, _ := newTestDB(t) + app := newTestApp(t, d) + plain, hook, err := d.CreateDeployHook(app.ID, "CI", "generic") + if err != nil { + t.Fatal(err) + } + if hook.LastUsedAt != nil { + t.Fatal("a fresh hook must not have a last-used time") + } + + before := time.Now().UTC() + got, err := d.ConsumeDeployHookToken(app.ID, plain, "10.0.0.9") + if err != nil || got == nil { + t.Fatalf("expected a match: %v %+v", err, got) + } + if got.LastUsedAt == nil || got.LastUsedAt.Before(before.Add(-time.Second)) { + t.Fatalf("last_used_at must be set to about now, got %v", got.LastUsedAt) + } + if got.LastUsedIP != "10.0.0.9" { + t.Fatalf("last_used_ip must record the caller, got %q", got.LastUsedIP) + } + + hooks, err := d.ListDeployHooks(app.ID) + if err != nil || len(hooks) != 1 || hooks[0].LastUsedIP != "10.0.0.9" { + t.Fatalf("the list must reflect the same usage: %v %+v", err, hooks) + } +} + +func TestConsumeDeployHookTokenRejectsWrongOrMissingTokens(t *testing.T) { + d, _ := newTestDB(t) + app := newTestApp(t, d) + if _, _, err := d.CreateDeployHook(app.ID, "CI", "generic"); err != nil { + t.Fatal(err) + } + + if got, err := d.ConsumeDeployHookToken(app.ID, "sfd_wrong", "ip"); err != nil || got != nil { + t.Fatalf("a wrong token must not match: %v %+v", err, got) + } + if got, err := d.ConsumeDeployHookToken(app.ID, "", "ip"); err != nil || got != nil { + t.Fatalf("an empty token must never match: %v %+v", err, got) + } +} + +func TestRevokeDeployHookStopsItWorkingAndLeavesOthersAlone(t *testing.T) { + d, _ := newTestDB(t) + app := newTestApp(t, d) + plainA, hookA, err := d.CreateDeployHook(app.ID, "A", "generic") + if err != nil { + t.Fatal(err) + } + plainB, hookB, err := d.CreateDeployHook(app.ID, "B", "generic") + if err != nil { + t.Fatal(err) + } + + if err := d.RevokeDeployHook(app.ID, hookA.ID); err != nil { + t.Fatal(err) + } + if got, err := d.ConsumeDeployHookToken(app.ID, plainA, "ip"); err != nil || got != nil { + t.Fatalf("a revoked hook must stop matching: %v %+v", err, got) + } + if got, err := d.ConsumeDeployHookToken(app.ID, plainB, "ip"); err != nil || got == nil || got.ID != hookB.ID { + t.Fatalf("revoking one hook must not affect another: %v %+v", err, got) + } + + if err := d.RevokeDeployHook(app.ID, hookA.ID); !errors.Is(err, ErrDeployHookNotFound) { + t.Fatalf("revoking an already-gone hook must report not found, got %v", err) + } +} + +func TestListDeployHooksIsScopedPerAppAndNewestFirst(t *testing.T) { + d, _ := newTestDB(t) + appA := newTestApp(t, d) + appB := newTestApp(t, d) + _, first, err := d.CreateDeployHook(appA.ID, "first", "generic") + if err != nil { + t.Fatal(err) + } + if _, _, err := d.CreateDeployHook(appA.ID, "second", "generic"); err != nil { + t.Fatal(err) + } + // created_at can have only second-level precision depending on the platform and driver, so two + // hooks made in the same test can tie. Backdate the first explicitly instead of sleeping across a + // second boundary, which is slow and still not guaranteed to land on the right side of it. + if _, err := d.Exec(`UPDATE app_deploy_hooks SET created_at = ? WHERE id = ?`, first.CreatedAt.Add(-time.Hour), first.ID); err != nil { + t.Fatal(err) + } + if _, _, err := d.CreateDeployHook(appB.ID, "other app", "generic"); err != nil { + t.Fatal(err) + } + + hooks, err := d.ListDeployHooks(appA.ID) + if err != nil || len(hooks) != 2 || hooks[0].Name != "second" || hooks[1].Name != "first" { + t.Fatalf("expected [second, first] for appA: %v %+v", err, hooks) + } + + other, err := d.ListDeployHooks(appB.ID) + if err != nil || len(other) != 1 || other[0].Name != "other app" { + t.Fatalf("appB must only see its own hook: %v %+v", err, other) + } + + none, err := d.ListDeployHooks("does-not-exist") + if err != nil || len(none) != 0 { + t.Fatalf("an unknown app has no hooks, never an error: %v %+v", err, none) + } +} diff --git a/internal/db/models.go b/internal/db/models.go index 77d739b7..2cef142a 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -14,37 +14,37 @@ import ( // Node represents a node in the cluster type Node struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - APIEndpoint string `json:"api_endpoint" db:"api_endpoint"` - APIKey string `json:"api_key" db:"api_key"` // For authenticating requests to this node - IsPrimary bool `json:"is_primary" db:"is_primary"` - Status string `json:"status" db:"status"` // online, offline, unreachable - LastSeen *time.Time `json:"last_seen" db:"last_seen"` - ConsecutiveFailures int `json:"consecutive_failures" db:"consecutive_failures"` // Track health check failures - LastHealthCheck *time.Time `json:"last_health_check" db:"last_health_check"` // When we last checked this node - LastLatencyMs int `json:"last_latency_ms" db:"last_latency_ms"` // Round trip of the last successful check, 0 if none yet - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + APIEndpoint string `json:"api_endpoint" db:"api_endpoint"` + APIKey string `json:"api_key" db:"api_key"` // For authenticating requests to this node + IsPrimary bool `json:"is_primary" db:"is_primary"` + Status string `json:"status" db:"status"` // online, offline, unreachable + LastSeen *time.Time `json:"last_seen" db:"last_seen"` + ConsecutiveFailures int `json:"consecutive_failures" db:"consecutive_failures"` // Track health check failures + LastHealthCheck *time.Time `json:"last_health_check" db:"last_health_check"` // When we last checked this node + LastLatencyMs int `json:"last_latency_ms" db:"last_latency_ms"` // Round trip of the last successful check, 0 if none yet + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // App represents a self-hosted application type App struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - ComposeContent string `json:"compose_content" db:"compose_content"` - TunnelToken string `json:"tunnel_token" db:"tunnel_token"` - TunnelID string `json:"tunnel_id" db:"tunnel_id"` - TunnelDomain string `json:"tunnel_domain" db:"tunnel_domain"` - PublicURL string `json:"public_url" db:"public_url"` - Status string `json:"status" db:"status"` // running, stopped, updating, error - ErrorMessage *string `json:"error_message" db:"error_message"` // Make nullable to handle NULL values - NodeID string `json:"node_id" db:"node_id"` // Which node this app is deployed on - TunnelMode string `json:"tunnel_mode" db:"tunnel_mode"` // "custom" | "quick" | "" (empty = no tunnel) - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - Schedule *AppSchedule `json:"schedule,omitempty" db:"-"` // Optional schedule (not stored in apps table) + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + ComposeContent string `json:"compose_content" db:"compose_content"` + TunnelToken string `json:"tunnel_token" db:"tunnel_token"` + TunnelID string `json:"tunnel_id" db:"tunnel_id"` + TunnelDomain string `json:"tunnel_domain" db:"tunnel_domain"` + PublicURL string `json:"public_url" db:"public_url"` + Status string `json:"status" db:"status"` // running, stopped, updating, error + ErrorMessage *string `json:"error_message" db:"error_message"` // Make nullable to handle NULL values + NodeID string `json:"node_id" db:"node_id"` // Which node this app is deployed on + TunnelMode string `json:"tunnel_mode" db:"tunnel_mode"` // "custom" | "quick" | "" (empty = no tunnel) + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Schedule *AppSchedule `json:"schedule,omitempty" db:"-"` // Optional schedule (not stored in apps table) } // CloudflareTunnel represents Cloudflare tunnel configuration and metadata @@ -86,24 +86,24 @@ type User struct { // Settings holds application settings type Settings struct { - ID string `json:"id" db:"id"` - + ID string `json:"id" db:"id"` + // DEPRECATED: Keep for backward compatibility during migration // Use TunnelProviderConfig instead for new implementations - CloudflareAPIToken *string `json:"cloudflare_api_token,omitempty" db:"cloudflare_api_token"` - CloudflareAccountID *string `json:"cloudflare_account_id,omitempty" db:"cloudflare_account_id"` - + CloudflareAPIToken *string `json:"cloudflare_api_token,omitempty" db:"cloudflare_api_token"` + CloudflareAccountID *string `json:"cloudflare_account_id,omitempty" db:"cloudflare_account_id"` + // New multi-provider tunnel configuration // ActiveTunnelProvider identifies which tunnel provider is currently active // (e.g., "cloudflare") - ActiveTunnelProvider *string `json:"active_tunnel_provider,omitempty" db:"active_tunnel_provider"` - + ActiveTunnelProvider *string `json:"active_tunnel_provider,omitempty" db:"active_tunnel_provider"` + // TunnelProviderConfig stores provider-specific configuration as JSON // Structure: {"cloudflare": {"api_token": "...", "account_id": "..."}} - TunnelProviderConfig *string `json:"tunnel_provider_config,omitempty" db:"tunnel_provider_config"` - - AutoStartApps bool `json:"auto_start_apps" db:"auto_start_apps"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + TunnelProviderConfig *string `json:"tunnel_provider_config,omitempty" db:"tunnel_provider_config"` + + AutoStartApps bool `json:"auto_start_apps" db:"auto_start_apps"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // NewNode creates a new Node with a generated UUID (or uses provided ID if not empty) @@ -190,25 +190,38 @@ func NewSettings() *Settings { // ComposeVersion represents a versioned snapshot of a compose file type ComposeVersion struct { - ID string `json:"id" db:"id"` - AppID string `json:"app_id" db:"app_id"` - Version int `json:"version" db:"version"` // Sequential version number - ComposeContent string `json:"compose_content" db:"compose_content"` // The actual compose file content - ChangeReason *string `json:"change_reason" db:"change_reason"` // Optional reason for the change - ChangedBy *string `json:"changed_by" db:"changed_by"` // Optional user who made the change - IsCurrent bool `json:"is_current" db:"is_current"` // Whether this is the active version - CreatedAt time.Time `json:"created_at" db:"created_at"` - RolledBackFrom *int `json:"rolled_back_from" db:"rolled_back_from"` // Version number this was rolled back from (if applicable) + ID string `json:"id" db:"id"` + AppID string `json:"app_id" db:"app_id"` + Version int `json:"version" db:"version"` // Sequential version number + ComposeContent string `json:"compose_content" db:"compose_content"` // The actual compose file content + ChangeReason *string `json:"change_reason" db:"change_reason"` // Optional reason for the change + ChangedBy *string `json:"changed_by" db:"changed_by"` // Optional user who made the change + IsCurrent bool `json:"is_current" db:"is_current"` // Whether this is the active version + CreatedAt time.Time `json:"created_at" db:"created_at"` + RolledBackFrom *int `json:"rolled_back_from" db:"rolled_back_from"` // Version number this was rolled back from (if applicable) +} + +// DeployHook is a named, per-app secret an external pipeline presents to trigger a pull and +// restart. TokenHash is never sent to the client; the plaintext token exists only at creation. +type DeployHook struct { + ID string `json:"id" db:"id"` + AppID string `json:"app_id" db:"app_id"` + Name string `json:"name" db:"name"` + SourceKind string `json:"source_kind" db:"source_kind"` + TokenHash string `json:"-" db:"token_hash"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + LastUsedAt *time.Time `json:"last_used_at" db:"last_used_at"` + LastUsedIP string `json:"last_used_ip" db:"last_used_ip"` } // AppSchedule represents a scheduling configuration for an app type AppSchedule struct { ID string `json:"id" db:"id"` AppID string `json:"app_id" db:"app_id"` - StartCron string `json:"start_cron" db:"start_cron"` // Cron expression for when to start - StopCron string `json:"stop_cron" db:"stop_cron"` // Cron expression for when to stop - Timezone string `json:"timezone" db:"timezone"` // IANA timezone (e.g., "America/New_York") - Enabled bool `json:"enabled" db:"enabled"` // Whether the schedule is active + StartCron string `json:"start_cron" db:"start_cron"` // Cron expression for when to start + StopCron string `json:"stop_cron" db:"stop_cron"` // Cron expression for when to stop + Timezone string `json:"timezone" db:"timezone"` // IANA timezone (e.g., "America/New_York") + Enabled bool `json:"enabled" db:"enabled"` // Whether the schedule is active CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -228,22 +241,22 @@ type Job struct { CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - + // Worker tracking for multi-worker support ClaimedBy *string `json:"claimed_by,omitempty" db:"claimed_by"` ClaimedAt *time.Time `json:"claimed_at,omitempty" db:"claimed_at"` - + // Retry support RetryCount int `json:"retry_count" db:"retry_count"` MaxRetries int `json:"max_retries" db:"max_retries"` RetryAfter *time.Time `json:"retry_after,omitempty" db:"retry_after"` - + // Cancellation support CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"` - + // Timeout in seconds TimeoutSeconds *int `json:"timeout_seconds,omitempty" db:"timeout_seconds"` - + // Deduplication hash JobHash *string `json:"job_hash,omitempty" db:"job_hash"` } diff --git a/internal/db/versioned.go b/internal/db/versioned.go index 18ae2184..98de9878 100644 --- a/internal/db/versioned.go +++ b/internal/db/versioned.go @@ -61,6 +61,23 @@ var versionedMigrations = []versionedMigration{ `ALTER TABLE audit_log ADD COLUMN target_name TEXT NOT NULL DEFAULT ''`, }, }, + { + version: 3, + name: "app deploy hooks", + stmts: []string{ + `CREATE TABLE IF NOT EXISTS app_deploy_hooks ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + name TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'generic', + token_hash TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL, + last_used_at DATETIME, + last_used_ip TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_app_deploy_hooks_app_id ON app_deploy_hooks(app_id)`, + }, + }, } // LatestSchemaVersion is the highest versioned migration this binary knows diff --git a/internal/domain/errors.go b/internal/domain/errors.go index da773877..7a9ef501 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -58,6 +58,12 @@ var ( Code: "TUNNEL_NOT_CONFIGURED", Message: "Cloudflare not configured", } + + // ErrDeployHookNotFound: no deploy hook exists for that app with that id + ErrDeployHookNotFound = &DomainError{ + Code: "DEPLOY_HOOK_NOT_FOUND", + Message: "deploy hook not found", + } ) // ============================================================================ @@ -181,7 +187,8 @@ func IsNotFoundError(err error) bool { domainErr.Code == ErrComposeVersionNotFound.Code || domainErr.Code == codeSettingsNotFound || domainErr.Code == codeNodeNotFound || - domainErr.Code == codeJobNotFound + domainErr.Code == codeJobNotFound || + domainErr.Code == ErrDeployHookNotFound.Code } return false } diff --git a/internal/domain/security.go b/internal/domain/security.go index 04944310..d582691c 100644 --- a/internal/domain/security.go +++ b/internal/domain/security.go @@ -11,6 +11,9 @@ import ( // ErrRegistrationUnauthorized: the registration token is wrong, expired or was already used var ErrRegistrationUnauthorized = errors.New("invalid registration token") +// ErrDeployTriggerUnauthorized: the deploy hook token is missing, unknown, or does not belong to this app +var ErrDeployTriggerUnauthorized = errors.New("invalid deploy hook token") + // SecurityService defines the primary port for the administrative security use cases: join tokens, // ending sessions and the audit log. type SecurityService interface { @@ -32,6 +35,36 @@ const ( AuditTargetNode = "node" ) +// DeployHook is a named, per-app secret an external pipeline presents to trigger a pull and +// restart. The token itself is never read back after creation; only this metadata is. +type DeployHook struct { + ID string `json:"id"` + AppID string `json:"app_id"` + Name string `json:"name"` + SourceKind string `json:"source_kind"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` + LastUsedIP string `json:"last_used_ip"` +} + +// DeployHookService defines the primary port for creating, listing and revoking per-app deploy +// hooks, and for verifying one and triggering the update it authorizes. A hook's SourceKind names +// which class of caller it is for (today only DeploySourceGeneric exists); adding a kind that needs +// more than a bearer-token check means adding a verifier in internal/service, not changing this port. +type DeployHookService interface { + // CreateDeployHook issues a new hook for an app. Only its hash is stored, so the plaintext token + // is returned once and cannot be recovered later. + CreateDeployHook(ctx context.Context, appID, name string) (token string, hook *DeployHook, err error) + // ListDeployHooks returns an app's hooks, newest first, never nil. Tokens are never included. + ListDeployHooks(ctx context.Context, appID string) ([]*DeployHook, error) + // RevokeDeployHook deletes one hook. It stops working immediately; other hooks on the app are unaffected. + RevokeDeployHook(ctx context.Context, appID, hookID string) error + // TriggerDeploy verifies a presented token against appID's hooks and, on success, starts the same + // update job the manual Update button starts. callerIP is recorded against the hook, never used + // to authenticate. Returns ErrDeployTriggerUnauthorized for a missing, wrong or revoked token. + TriggerDeploy(ctx context.Context, appID, presentedToken, callerIP string) (*db.Job, *DeployHook, error) +} + // AutoRegisterRequest is what a secondary sends to register itself with the primary type AutoRegisterRequest struct { ID string diff --git a/internal/gateway/auth.go b/internal/gateway/auth.go index e06ae01a..713b35be 100644 --- a/internal/gateway/auth.go +++ b/internal/gateway/auth.go @@ -66,6 +66,13 @@ func (c *Config) pathSkipsAuth(path string) bool { return true } + // A deploy hook's bearer token is not a JWT: it is an opaque per-app secret the backend checks + // itself. Without this, the gateway would try to parse it as one and reject every call here + // before the backend ever saw it. + if strings.HasPrefix(path, "/api/apps/") && strings.HasSuffix(path, "/deploy-trigger") { + return true + } + return false } diff --git a/internal/gateway/auth_test.go b/internal/gateway/auth_test.go index c288f10a..b23b29e5 100644 --- a/internal/gateway/auth_test.go +++ b/internal/gateway/auth_test.go @@ -160,6 +160,8 @@ func TestConfig_pathSkipsAuth(t *testing.T) { {"health endpoint", "/api/health", http.MethodGet, true}, {"health POST", "/api/health", http.MethodPost, true}, {"me endpoint", "/api/me", http.MethodGet, true}, + {"deploy trigger", "/api/apps/app-123/deploy-trigger", http.MethodPost, true}, + {"deploy hooks management is not skipped, only the trigger is", "/api/apps/app-123/deploy-hooks", http.MethodPost, false}, {"protected path", "/api/apps", http.MethodGet, false}, {"other path", "/api/other", http.MethodGet, false}, } diff --git a/internal/http/app_deploy_hooks.go b/internal/http/app_deploy_hooks.go new file mode 100644 index 00000000..21082d46 --- /dev/null +++ b/internal/http/app_deploy_hooks.go @@ -0,0 +1,114 @@ +package http + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/selfhostly/internal/domain" +) + +// CreateDeployHookRequest names the new hook. The source kind is not client-supplied: every hook +// made through this route is constants.DeploySourceGeneric until a second kind exists to choose from. +type CreateDeployHookRequest struct { + Name string `json:"name" binding:"required"` +} + +// createdDeployHookResponse embeds the same shape listDeployHooks returns, plus the plaintext token +// - the one field list never includes. Embedding, rather than hand-listing fields again, keeps the +// two responses from drifting apart as domain.DeployHook gains fields. +type createdDeployHookResponse struct { + domain.DeployHook + Token string `json:"token"` +} + +// createDeployHook issues a new deploy hook for an app. Session-authed like any other app route; +// the token it returns, not this route, is what an external pipeline uses. +func (s *Server) createDeployHook(c *gin.Context) { + appID := c.Param("id") + var req CreateDeployHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body", Details: domain.PublicMessage(err)}) + return + } + + token, hook, err := s.deployHookService.CreateDeployHook(c.Request.Context(), appID, req.Name) + if err != nil { + s.handleServiceError(c, "create deploy hook", err) + return + } + // The plaintext token is returned exactly once, here, and never again: the database only ever + // holds its hash. + c.JSON(http.StatusCreated, createdDeployHookResponse{DeployHook: *hook, Token: token}) +} + +// listDeployHooks returns an app's hooks. Tokens are never included; only metadata is. +func (s *Server) listDeployHooks(c *gin.Context) { + appID := c.Param("id") + hooks, err := s.deployHookService.ListDeployHooks(c.Request.Context(), appID) + if err != nil { + s.handleServiceError(c, "list deploy hooks", err) + return + } + c.JSON(http.StatusOK, hooks) +} + +// revokeDeployHook deletes one hook. It stops working immediately. +func (s *Server) revokeDeployHook(c *gin.Context) { + appID := c.Param("id") + hookID := c.Param("hookId") + if err := s.deployHookService.RevokeDeployHook(c.Request.Context(), appID, hookID); err != nil { + s.handleServiceError(c, "revoke deploy hook", err) + return + } + c.Status(http.StatusNoContent) +} + +// bearerToken extracts the token from an "Authorization: Bearer " header, or "" when the +// header is missing or a different scheme. +func bearerToken(header string) string { + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "" + } + return header[len(prefix):] +} + +// triggerDeployUpdate is the endpoint an external pipeline calls, authenticated purely by the +// per-app deploy hook token in the Authorization header. It needs no session and no node +// credentials, so it is mounted outside the normal /api auth group (see routes.go); the token is +// the only thing that authorizes it, and it authorizes nothing beyond triggering an update for the +// one app named in the path, the same job the dashboard's own Update button starts. +func (s *Server) triggerDeployUpdate(c *gin.Context) { + appID := c.Param("id") + if appID == "" { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid app ID"}) + return + } + token := bearerToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "Missing deploy hook token", Details: "send it as Authorization: Bearer "}) + return + } + + job, hook, err := s.deployHookService.TriggerDeploy(c.Request.Context(), appID, token, c.ClientIP()) + if errors.Is(err, domain.ErrDeployTriggerUnauthorized) { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "Invalid deploy hook token"}) + return + } + if err != nil { + s.handleServiceError(c, "trigger deploy", err) + return + } + + // Named here so the audit entry's actor reads "deploy-hook:" instead of "anonymous" - + // there is no session or node identity on this request for actorFor to fall back to. The + // target stays the app itself (resolved automatically from the URL, like every other app route). + c.Set("deploy_hook_actor", "deploy-hook:"+hook.Name) + c.JSON(http.StatusAccepted, gin.H{ + "job_id": job.ID, + "status": job.Status, + "message": "App update started in background", + }) +} diff --git a/internal/http/app_deploy_hooks_test.go b/internal/http/app_deploy_hooks_test.go new file mode 100644 index 00000000..379e7b9c --- /dev/null +++ b/internal/http/app_deploy_hooks_test.go @@ -0,0 +1,159 @@ +package http + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/selfhostly/internal/constants" + "github.com/selfhostly/internal/db" +) + +// createTestApp inserts an app directly, the same shortcut internal/db's own tests use, so these +// tests do not depend on the create-app HTTP flow. +func createTestApp(t *testing.T, database *db.DB, name string) *db.App { + t.Helper() + app := db.NewApp(name, "", "services: {}") + if err := database.CreateApp(app); err != nil { + t.Fatal(err) + } + return app +} + +func createHook(t *testing.T, s *Server, appID, name string) (id, token string) { + t.Helper() + w := do(s, "POST", "/api/apps/"+appID+"/deploy-hooks", map[string]string{"name": name}, gatewayAuth) + if w.Code != http.StatusCreated { + t.Fatalf("create hook: %d %s", w.Code, w.Body) + } + var resp struct { + ID string `json:"id"` + Token string `json:"token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp.ID, resp.Token +} + +func TestDeployHookCreateListRevoke(t *testing.T) { + s, database := newTestServer(t, constants.SecurityModeEnforce) + app := createTestApp(t, database, "hooked") + + id, token := createHook(t, s, app.ID, "GitHub Actions") + if id == "" || token == "" { + t.Fatalf("expected an id and a plaintext token, got %q %q", id, token) + } + + w := do(s, "GET", "/api/apps/"+app.ID+"/deploy-hooks", nil, gatewayAuth) + if w.Code != http.StatusOK { + t.Fatalf("list: %d %s", w.Code, w.Body) + } + if strings.Contains(w.Body.String(), token) { + t.Fatal("the list response must never include the plaintext token") + } + // Decode into a plain map, not a Go struct: json.Unmarshal matches a struct field by name + // case-insensitively when there is no exact tag match, which would silently hide a response + // missing its snake_case keys (app_id, source_kind, created_at, ...) - exactly the bug this + // test exists to catch. + var raw []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if len(raw) != 1 { + t.Fatalf("expected one hook, got %+v", raw) + } + for _, field := range []string{"id", "app_id", "name", "source_kind", "created_at", "last_used_at", "last_used_ip"} { + if _, ok := raw[0][field]; !ok { + t.Errorf("list response is missing %q: %+v", field, raw[0]) + } + } + if raw[0]["id"] != id || raw[0]["name"] != "GitHub Actions" || raw[0]["app_id"] != app.ID { + t.Fatalf("unexpected list: %+v", raw) + } + if createdAt, _ := raw[0]["created_at"].(string); createdAt == "" || strings.HasPrefix(createdAt, "0001-01-01") { + t.Fatalf("created_at must be the real creation time, got %v", raw[0]["created_at"]) + } + + var hooks []db.DeployHook + if err := json.Unmarshal(w.Body.Bytes(), &hooks); err != nil { + t.Fatal(err) + } + if len(hooks) != 1 || hooks[0].ID != id || hooks[0].Name != "GitHub Actions" || hooks[0].AppID != app.ID || hooks[0].CreatedAt.IsZero() { + t.Fatalf("unexpected list, decoded into db.DeployHook: %+v", hooks) + } + + if w := do(s, "DELETE", "/api/apps/"+app.ID+"/deploy-hooks/"+id, nil, gatewayAuth); w.Code != http.StatusNoContent { + t.Fatalf("revoke: %d %s", w.Code, w.Body) + } + if w := do(s, "DELETE", "/api/apps/"+app.ID+"/deploy-hooks/"+id, nil, gatewayAuth); w.Code != http.StatusNotFound { + t.Fatalf("revoking an already-gone hook must be 404, got %d", w.Code) + } +} + +func TestDeployTriggerAcceptsOnlyItsOwnAppsValidToken(t *testing.T) { + s, database := newTestServer(t, constants.SecurityModeEnforce) + appA := createTestApp(t, database, "trigger-a") + appB := createTestApp(t, database, "trigger-b") + _, tokenA := createHook(t, s, appA.ID, "hook-a") + + if w := do(s, "POST", "/api/apps/"+appA.ID+"/deploy-trigger", nil, nil); w.Code != http.StatusUnauthorized { + t.Fatalf("missing token must be 401, got %d", w.Code) + } + if w := do(s, "POST", "/api/apps/"+appA.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer wrong"}); w.Code != http.StatusUnauthorized { + t.Fatalf("wrong token must be 401, got %d", w.Code) + } + if w := do(s, "POST", "/api/apps/"+appB.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer " + tokenA}); w.Code != http.StatusUnauthorized { + t.Fatalf("a token for a different app must be 401, got %d", w.Code) + } + + w := do(s, "POST", "/api/apps/"+appA.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer " + tokenA}) + if w.Code != http.StatusAccepted { + t.Fatalf("a valid token for its own app must be 202, got %d: %s", w.Code, w.Body) + } + var resp struct { + JobID string `json:"job_id"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.JobID == "" { + t.Fatal("expected a job id, the same as the manual Update button would get") + } + + // The audit trail names the hook, not a user - there is no session on this request. + entries, err := database.ListAudit(10) + if err != nil || len(entries) == 0 { + t.Fatalf("expected an audit record: %v %v", err, entries) + } + if entries[0].Actor != "deploy-hook:hook-a" || entries[0].Action != "app.deploy_trigger" { + t.Fatalf("unexpected audit record %+v", entries[0]) + } +} + +func TestDeployTriggerStopsWorkingOnceRevoked(t *testing.T) { + s, database := newTestServer(t, constants.SecurityModeEnforce) + app := createTestApp(t, database, "revoke-then-trigger") + id, token := createHook(t, s, app.ID, "temp") + + if w := do(s, "POST", "/api/apps/"+app.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer " + token}); w.Code != http.StatusAccepted { + t.Fatalf("first trigger should succeed: %d %s", w.Code, w.Body) + } + do(s, "DELETE", "/api/apps/"+app.ID+"/deploy-hooks/"+id, nil, gatewayAuth) + if w := do(s, "POST", "/api/apps/"+app.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer " + token}); w.Code != http.StatusUnauthorized { + t.Fatalf("a revoked token must stop working, got %d", w.Code) + } +} + +func TestDeployTriggerRateLimit(t *testing.T) { + s, database := newTestServer(t, constants.SecurityModeEnforce) + app := createTestApp(t, database, "rate-limited") + + var last int + for i := 0; i < constants.DeployTriggerRateLimitAttempts+2; i++ { + w := do(s, "POST", "/api/apps/"+app.ID+"/deploy-trigger", nil, map[string]string{"Authorization": "Bearer wrong"}) + last = w.Code + } + if last != http.StatusTooManyRequests { + t.Fatalf("expected 429 after the budget is spent, got %d", last) + } +} diff --git a/internal/http/audit_actions.go b/internal/http/audit_actions.go index e17e4192..e174ce92 100644 --- a/internal/http/audit_actions.go +++ b/internal/http/audit_actions.go @@ -37,6 +37,9 @@ var auditRoutes = map[string]auditRoute{ "POST /api/apps/:id/schedule": {"schedule.save", targetApp, "id"}, "DELETE /api/apps/:id/schedule": {"schedule.delete", targetApp, "id"}, "POST /api/apps/:id/compose/rollback/:version": {"app.restore_version", targetApp, "id"}, + "POST /api/apps/:id/deploy-hooks": {"app.deploy_hook.create", targetApp, "id"}, + "DELETE /api/apps/:id/deploy-hooks/:hookId": {"app.deploy_hook.revoke", targetApp, "id"}, + "POST /api/apps/:id/deploy-trigger": {"app.deploy_trigger", targetApp, "id"}, "POST /api/tunnels/apps/:appId": {"tunnel.create", targetApp, "appId"}, "POST /api/tunnels/apps/:appId/switch-to-custom": {"tunnel.switch_to_custom", targetApp, "appId"}, "POST /api/tunnels/apps/:appId/sync": {"tunnel.sync", targetApp, "appId"}, diff --git a/internal/http/node_link.go b/internal/http/node_link.go index c2ff9b79..69828af7 100644 --- a/internal/http/node_link.go +++ b/internal/http/node_link.go @@ -132,38 +132,77 @@ func (s *Server) forwardToLinkedNode() gin.HandlerFunc { c.Next() // unknown or directly reached: the existing handling applies return } - if !s.nodeLinks.Connected(nodeID) { - c.AbortWithStatusJSON(http.StatusServiceUnavailable, ErrorResponse{ - Error: "Node is not connected", - Details: "the node " + node.Name + " has no live link to the primary right now", - }) + s.proxyToLinkedNode(c, node, func(pr *httputil.ProxyRequest) { + out := pr.Out + for _, h := range []string{"Cookie", "Authorization", "Origin", "Referer", "X-Xsrf-Token", + constants.HeaderGatewayAPIKey, constants.HeaderCFAccessJWT} { + out.Header.Del(h) + } + out.Header.Set(constants.HeaderNodeID, node.ID) + out.Header.Set(constants.HeaderNodeAPIKey, node.APIKey) + }) + } +} + +// forwardDeployTriggerToLinkedNode is forwardToLinkedNode's counterpart for POST +// /api/apps/:id/deploy-trigger, which sits outside the user-or-node auth group entirely (see +// routes.go) and so never has request_scope set - there is no user session or node credential on +// this request to key that check on in the first place. +// +// Unlike forwardToLinkedNode, this deliberately keeps the Authorization header instead of +// stripping it: here Authorization carries the deploy hook's bearer token, the only credential the +// request has, and it must reach the target node's own check. There is no user session to protect +// by removing it, and node credentials are never involved: the target's deploy-trigger route +// checks only the bearer token, not X-Node-ID/X-Node-API-Key. +func (s *Server) forwardDeployTriggerToLinkedNode() gin.HandlerFunc { + return func(c *gin.Context) { + nodeID := c.Query("node_id") + if nodeID == "" || nodeID == s.config.Node.ID { + c.Next() // this node is the target, or no node_id was given: handle locally return } - - proxy := &httputil.ReverseProxy{ - Transport: s.nodeLinks.RoundTripper(), - Rewrite: func(pr *httputil.ProxyRequest) { - out := pr.Out - out.URL.Scheme = constants.LinkEndpointScheme - out.URL.Host = node.ID - out.Host = "" - for _, h := range []string{"Cookie", "Authorization", "Origin", "Referer", "X-Xsrf-Token", - constants.HeaderGatewayAPIKey, constants.HeaderCFAccessJWT} { - out.Header.Del(h) - } - out.Header.Set(constants.HeaderNodeID, node.ID) - out.Header.Set(constants.HeaderNodeAPIKey, node.APIKey) - }, - ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - slog.Warn("forwarding to a linked node failed", "node_id", nodeID, "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`{"error":"Node did not answer"}`)) - }, + node, _ := s.nodeLinkService.LinkedNode(c.Request.Context(), nodeID) + if node == nil { + // Unknown, or a direct node - the gateway routes those straight to the node's own + // address, so this process should never be asked for one; handling it locally just + // means the trigger 401s or 404s instead of the request being dropped silently. + c.Next() + return } - proxy.ServeHTTP(c.Writer, c.Request) - c.Abort() + s.proxyToLinkedNode(c, node, func(pr *httputil.ProxyRequest) { + pr.Out.Header.Del("Cookie") // a CI caller never sends one, stripped for hygiene regardless + }) + } +} + +// proxyToLinkedNode relays the current request down a linked node's live connection, refusing if +// the link is not currently up. rewrite makes the header decisions a caller's trust model requires; +// the target address is always set here so neither caller can get that part wrong. +func (s *Server) proxyToLinkedNode(c *gin.Context, node *db.Node, rewrite func(*httputil.ProxyRequest)) { + if !s.nodeLinks.Connected(node.ID) { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, ErrorResponse{ + Error: "Node is not connected", + Details: "the node " + node.Name + " has no live link to the primary right now", + }) + return } + proxy := &httputil.ReverseProxy{ + Transport: s.nodeLinks.RoundTripper(), + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = constants.LinkEndpointScheme + pr.Out.URL.Host = node.ID + pr.Out.Host = "" + rewrite(pr) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + slog.Warn("forwarding to a linked node failed", "node_id", node.ID, "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":"Node did not answer"}`)) + }, + } + proxy.ServeHTTP(c.Writer, c.Request) + c.Abort() } // peekNodeID reads node_id from a JSON body and puts the body back for the handler diff --git a/internal/http/node_link_test.go b/internal/http/node_link_test.go index 244769c3..f8c89110 100644 --- a/internal/http/node_link_test.go +++ b/internal/http/node_link_test.go @@ -320,6 +320,133 @@ func TestRequestToAnUnconnectedLinkedNodeIsServiceUnavailable(t *testing.T) { } } +// TestDeployHookCreationIsForwardedToALinkedNode is the regression test for a bug live testing +// caught: creating a hook for an app on a linked node is only reachable through +// forwardToLinkedNode, which re-signs the forwarded request with the target's own node credentials +// (the secondary has no way to see the original session). A denyNodeAuthMiddleware guard on that +// route, briefly added during review, silently broke this legitimate path - a node's own +// credentials already give it full control over apps hosted on it (start/stop/update/delete all +// allow node auth too), so the guard stopped nothing a node could not already do to its own app. +func TestDeployHookCreationIsForwardedToALinkedNode(t *testing.T) { + f := newLinkFixture(t) + sec := newTestSecondary(t, "sec1", "sec1") + f.dial(t, "sec1", "sec1", linkNodeKey, f.joinToken(t), sec.engine) + f.waitStatus(t, "sec1", constants.NodeStatusOnline) + + app := db.NewApp("hook-via-forward", "", "services: {}") + if err := sec.database.CreateApp(app); err != nil { + t.Fatal(err) + } + + resp, err := http.Post(f.httpServer.URL+"/api/apps/"+app.ID+"/deploy-hooks?node_id=sec1", + "application/json", strings.NewReader(`{"name":"forwarded"}`)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("creating a hook for an app on a linked node must succeed, got %d: %s", resp.StatusCode, body) + } + + // Stored on the secondary's own database, not the primary's - same as the app itself. + hooks, err := sec.database.ListDeployHooks(app.ID) + if err != nil || len(hooks) != 1 || hooks[0].Name != "forwarded" { + t.Fatalf("expected the hook on the secondary's own database: %v %+v", err, hooks) + } +} + +// TestDeployTriggerIsForwardedToALinkedNodeKeepingAuthorization exercises the whole path end to +// end: a real secondary, its own local hook, forwarded over a real link by the primary. It is the +// regression test for the bug this forwarding was added to fix - the deploy-trigger route used to +// have no node-forwarding at all, so a hook for an app on any node but the one that received the +// HTTP request always 401'd. +func TestDeployTriggerIsForwardedToALinkedNodeKeepingAuthorization(t *testing.T) { + f := newLinkFixture(t) + sec := newTestSecondary(t, "sec1", "sec1") + f.dial(t, "sec1", "sec1", linkNodeKey, f.joinToken(t), sec.engine) + f.waitStatus(t, "sec1", constants.NodeStatusOnline) + + // The app and its hook live only on the secondary's own local database, exactly as they would + // on a real deployment: the primary has no row for either. + app := db.NewApp("on-the-secondary", "", "services: {}") + if err := sec.database.CreateApp(app); err != nil { + t.Fatal(err) + } + token, _, err := sec.database.CreateDeployHook(app.ID, "ci", constants.DeploySourceGeneric) + if err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest("POST", f.httpServer.URL+"/api/apps/"+app.ID+"/deploy-trigger?node_id=sec1", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("a valid token for an app on the linked node must be accepted, got %d: %s", resp.StatusCode, body) + } + var accepted struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(body, &accepted); err != nil || accepted.JobID == "" { + t.Fatalf("expected a job id from the secondary that actually holds the app: %v %s", err, body) + } + + // The primary's own database never saw any of this - it has no app and no hook by this id. + if _, err := f.primaryDB.GetApp(app.ID); err == nil { + t.Fatal("the app must not exist on the primary; the request must have been handled by the secondary") + } +} + +// TestDeployTriggerRejectsAWrongTokenOnceForwarded confirms the token check still runs, on the +// secondary, after forwarding - forwarding is not itself the authorization. +func TestDeployTriggerRejectsAWrongTokenOnceForwarded(t *testing.T) { + f := newLinkFixture(t) + sec := newTestSecondary(t, "sec1", "sec1") + f.dial(t, "sec1", "sec1", linkNodeKey, f.joinToken(t), sec.engine) + f.waitStatus(t, "sec1", constants.NodeStatusOnline) + + app := db.NewApp("wrong-token-app", "", "services: {}") + if err := sec.database.CreateApp(app); err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest("POST", f.httpServer.URL+"/api/apps/"+app.ID+"/deploy-trigger?node_id=sec1", nil) + req.Header.Set("Authorization", "Bearer sfd_wrong") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("a wrong token must still be rejected after forwarding, got %d", resp.StatusCode) + } +} + +// TestDeployTriggerToAnUnconnectedLinkedNodeIsServiceUnavailable mirrors +// TestRequestToAnUnconnectedLinkedNodeIsServiceUnavailable for the deploy-trigger route. +func TestDeployTriggerToAnUnconnectedLinkedNodeIsServiceUnavailable(t *testing.T) { + f := newLinkFixture(t) + n := db.NewNodeWithID("gone", "gone", linkEndpoint("gone"), linkNodeKey, false) + if err := f.primaryDB.CreateNode(n); err != nil { + t.Fatal(err) + } + req, _ := http.NewRequest("POST", f.httpServer.URL+"/api/apps/abc/deploy-trigger?node_id=gone", nil) + req.Header.Set("Authorization", "Bearer sfd_whatever") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("a linked node with no live link must give 503, got %d", resp.StatusCode) + } +} + func TestDroppedLinkMarksTheNodeOfflineAtOnce(t *testing.T) { f := newLinkFixture(t) sec := newTestSecondary(t, "sec1", "sec1") @@ -337,7 +464,7 @@ func TestExistingDirectNodeSwitchesToTheLinkKeepingItsIdentity(t *testing.T) { } sec := newTestSecondary(t, "old1", "old1") f.dial(t, "old1", "old1", linkNodeKey, "", sec.engine) // no token: the node is recognised by its key - deadline := time.Now().Add(15 * time.Second) // same CI-contention margin as waitStatus + deadline := time.Now().Add(15 * time.Second) // same CI-contention margin as waitStatus for time.Now().Before(deadline) { if n, _ := f.primaryDB.GetNode("old1"); n != nil && n.APIEndpoint == "tunnel://old1" { return diff --git a/internal/http/routes.go b/internal/http/routes.go index 46543a21..db32b2cf 100644 --- a/internal/http/routes.go +++ b/internal/http/routes.go @@ -43,6 +43,22 @@ func (s *Server) setupRoutes() { // outside the user login group, and rate limited like registration. s.engine.GET(constants.LinkPath, s.registerRateLimitMiddleware(), s.nodeConnect) + // Deploy hook trigger: an external CI pipeline calls this, authenticated only by the per-app + // token in its Authorization header. It carries its own credential, so like registration and the + // node link it sits outside the user-or-node auth group, and is rate limited per app. + // + // It still needs to reach the right node: the gateway routes any /api/apps/:id/... path by the + // node_id query param, straight to that node's own address when it is directly reachable, or to + // the primary when it is a linked (tunnel) node - the same as every other by-id route. For the + // linked case, forwardDeployTriggerToLinkedNode does the second hop the primary must do itself, + // the same job forwardToLinkedNode does for session-authed routes but keeping Authorization + // instead of stripping it, since that header carries this route's only credential. + s.engine.POST("/api/apps/:id/deploy-trigger", + s.deployTriggerRateLimitMiddleware(), + s.forwardDeployTriggerToLinkedNode(), + s.triggerDeployUpdate, + ) + // Single API: user auth OR node auth (composite auth) api := s.engine.Group("/api") api.Use(s.userOrNodeAuthMiddleware()) @@ -130,6 +146,22 @@ func (s *Server) setupAppRoutes(api *gin.RouterGroup) { // Job routes for this app appSpecific.GET("/jobs", s.getAppJobs) + + // Deploy hooks: session-authed management (create/list/revoke). The hook is triggered + // through the /deploy-trigger route registered above, not here - that route carries its + // own credential (the hook's bearer token) instead of a session, the same reason node + // registration sits outside this group too, so it is not nested under appSpecific. + // + // Deliberately no denyNodeAuthMiddleware on create, unlike join-tokens: a node's own + // credentials already give it full control over apps hosted on it (start/stop/update/ + // delete all allow node auth too), and creating a hook for an app on a linked secondary + // is only reachable at all because forwardToLinkedNode re-signs the forwarded request + // with the target's node credentials - the secondary has no way to see the original + // session. Denying node auth here does not stop anything a node could not already do to + // its own app, and it silently breaks that legitimate forwarded path. + appSpecific.POST("/deploy-hooks", s.createDeployHook) + appSpecific.GET("/deploy-hooks", s.listDeployHooks) + appSpecific.DELETE("/deploy-hooks/:hookId", s.revokeDeployHook) } } } diff --git a/internal/http/security.go b/internal/http/security.go index 17956495..a109c61e 100644 --- a/internal/http/security.go +++ b/internal/http/security.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -104,6 +105,11 @@ func actorFor(c *gin.Context) string { return "cf:" + e } } + if actor, ok := c.Get("deploy_hook_actor"); ok { + if s, _ := actor.(string); s != "" { + return s + } + } if id, ok := c.Get("node_id"); ok { if s, _ := id.(string); s != "" { return "node:" + s @@ -204,6 +210,22 @@ func (s *Server) registerRateLimitMiddleware() gin.HandlerFunc { } } +// deployTriggerRateLimitMiddleware bounds attempts per app, not per caller: the deploy-trigger +// endpoint has no session to key on, and a CI runner's IP is not a stable identity, but an app's own +// budget still bounds how hard its hooks can be brute-forced or misfired regardless of source. +func (s *Server) deployTriggerRateLimitMiddleware() gin.HandlerFunc { + limiter := newAttemptLimiter(constants.DeployTriggerRateLimitAttempts, constants.DeployTriggerRateLimitWindow) + retryAfter := strconv.Itoa(int(constants.DeployTriggerRateLimitWindow.Seconds())) + return func(c *gin.Context) { + if !limiter.allow(c.Param("id"), time.Now()) { + c.Header("Retry-After", retryAfter) + c.AbortWithStatusJSON(http.StatusTooManyRequests, ErrorResponse{Error: "Too many attempts", Details: "try again later"}) + return + } + c.Next() + } +} + // cfAccessMiddleware requires a valid Cloudflare Access token when GitHub auth is not in use, so // the backend does not rely on network position alone. func (s *Server) cfAccessMiddleware() gin.HandlerFunc { diff --git a/internal/http/server.go b/internal/http/server.go index ca53d2e1..e1acbb50 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -32,30 +32,31 @@ import ( // Server wraps the HTTP server type Server struct { - config *config.Config - database *db.DB - dockerManager *docker.Manager - appService domain.AppService - tunnelService domain.TunnelService - systemService domain.SystemService - composeService domain.ComposeService - nodeService domain.NodeService - scheduleService domain.ScheduleService - jobWorker *jobs.Worker - scheduler *scheduler.Scheduler - engine *gin.Engine - authService *auth.Service - allowList *selfauth.AllowList - nodeLinks *nodelink.Registry - nodeLinkService domain.NodeLinkService - securityService domain.SecurityService - jobService domain.JobService - updateService domain.UpdateService - cfAccess *selfauth.CFAccessVerifier - httpServer *http.Server - shutdownCtx context.Context - shutdownCancel context.CancelFunc - events *events.Bus + config *config.Config + database *db.DB + dockerManager *docker.Manager + appService domain.AppService + tunnelService domain.TunnelService + systemService domain.SystemService + composeService domain.ComposeService + nodeService domain.NodeService + scheduleService domain.ScheduleService + jobWorker *jobs.Worker + scheduler *scheduler.Scheduler + engine *gin.Engine + authService *auth.Service + allowList *selfauth.AllowList + nodeLinks *nodelink.Registry + nodeLinkService domain.NodeLinkService + securityService domain.SecurityService + jobService domain.JobService + updateService domain.UpdateService + deployHookService domain.DeployHookService + cfAccess *selfauth.CFAccessVerifier + httpServer *http.Server + shutdownCtx context.Context + shutdownCancel context.CancelFunc + events *events.Bus } // NewServer creates a new HTTP server @@ -149,6 +150,7 @@ func NewServer(cfg *config.Config, database *db.DB) *Server { securityService := service.NewSecurityService(database) jobService := service.NewJobService(database) updateService := service.NewUpdateService(database, cfg) + deployHookService := service.NewDeployHookService(database, appService, appLogger) // Initialize job processing system jobProcessor := jobs.NewProcessor(database, dockerManager, appService, tunnelService, appLogger) @@ -165,29 +167,30 @@ func NewServer(cfg *config.Config, database *db.DB) *Server { // Initialize server *server = Server{ - config: cfg, - database: database, - dockerManager: dockerManager, - appService: appService, - tunnelService: tunnelService, - systemService: systemService, - composeService: composeService, - nodeService: nodeService, - scheduleService: scheduleService, - jobWorker: jobWorker, - scheduler: appScheduler, - engine: engine, - authService: authService, - allowList: allowList, - nodeLinks: links, - nodeLinkService: nodeLinkService, - securityService: securityService, - jobService: jobService, - updateService: updateService, - cfAccess: cfAccess, - shutdownCtx: shutdownCtx, - shutdownCancel: shutdownCancel, - events: server.events, + config: cfg, + database: database, + dockerManager: dockerManager, + appService: appService, + tunnelService: tunnelService, + systemService: systemService, + composeService: composeService, + nodeService: nodeService, + scheduleService: scheduleService, + jobWorker: jobWorker, + scheduler: appScheduler, + engine: engine, + authService: authService, + allowList: allowList, + nodeLinks: links, + nodeLinkService: nodeLinkService, + securityService: securityService, + jobService: jobService, + updateService: updateService, + deployHookService: deployHookService, + cfAccess: cfAccess, + shutdownCtx: shutdownCtx, + shutdownCancel: shutdownCancel, + events: server.events, } // Origin/content-type and audit middleware need the fully built server diff --git a/internal/service/deploy_hook_service.go b/internal/service/deploy_hook_service.go new file mode 100644 index 00000000..5cdf32f9 --- /dev/null +++ b/internal/service/deploy_hook_service.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "strings" + + "github.com/selfhostly/internal/constants" + "github.com/selfhostly/internal/db" + "github.com/selfhostly/internal/domain" +) + +// deploySourceVerifiers holds any extra, kind-specific check a deploy hook's SourceKind needs beyond +// the token match every hook already requires (ConsumeDeployHookToken already scopes the match to +// one app, so this is purely additional). A kind with no entry here, including the only kind that +// exists today (constants.DeploySourceGeneric), gets that shared check and nothing more. Adding a +// kind that needs more, for example one that also checks a caller's identity, means adding an entry +// here: no change to the schema, the route, or any other kind's behavior. +var deploySourceVerifiers = map[string]func(ctx context.Context, hook *db.DeployHook, callerIP string) error{} + +// deployHookService implements domain.DeployHookService +type deployHookService struct { + database *db.DB + appService domain.AppService + logger *slog.Logger +} + +// NewDeployHookService creates the service that manages per-app deploy hooks and verifies-and- +// triggers the update one authorizes. It calls into AppService for the trigger itself, so a +// hook-authenticated request starts the exact same job the manual Update button starts. +func NewDeployHookService(database *db.DB, appService domain.AppService, logger *slog.Logger) domain.DeployHookService { + return &deployHookService{database: database, appService: appService, logger: logger} +} + +func (s *deployHookService) CreateDeployHook(ctx context.Context, appID, name string) (string, *domain.DeployHook, error) { + if _, err := s.database.GetApp(appID); err != nil { + return "", nil, domain.WrapAppNotFound(appID, err) + } + name = strings.TrimSpace(name) + if name == "" { + return "", nil, domain.WrapValidationError("name", errors.New("name cannot be empty")) + } + + plain, hook, err := s.database.CreateDeployHook(appID, name, constants.DeploySourceGeneric) + if err != nil { + return "", nil, domain.WrapDatabaseOperation("create deploy hook", err) + } + s.logger.InfoContext(ctx, "deploy hook created", "appID", appID, "hookID", hook.ID, "name", hook.Name) + return plain, toDomainDeployHook(hook), nil +} + +func (s *deployHookService) ListDeployHooks(ctx context.Context, appID string) ([]*domain.DeployHook, error) { + hooks, err := s.database.ListDeployHooks(appID) + if err != nil { + return nil, domain.WrapDatabaseOperation("list deploy hooks", err) + } + out := make([]*domain.DeployHook, 0, len(hooks)) + for _, h := range hooks { + out = append(out, toDomainDeployHook(h)) + } + return out, nil +} + +func (s *deployHookService) RevokeDeployHook(ctx context.Context, appID, hookID string) error { + err := s.database.RevokeDeployHook(appID, hookID) + if errors.Is(err, db.ErrDeployHookNotFound) { + return domain.ErrDeployHookNotFound + } + if err != nil { + return domain.WrapDatabaseOperation("revoke deploy hook", err) + } + s.logger.InfoContext(ctx, "deploy hook revoked", "appID", appID, "hookID", hookID) + return nil +} + +func (s *deployHookService) TriggerDeploy(ctx context.Context, appID, presentedToken, callerIP string) (*db.Job, *domain.DeployHook, error) { + hook, err := s.database.ConsumeDeployHookToken(appID, presentedToken, callerIP) + if err != nil { + return nil, nil, domain.WrapDatabaseOperation("verify deploy hook", err) + } + if hook == nil { + return nil, nil, domain.ErrDeployTriggerUnauthorized + } + if verify, ok := deploySourceVerifiers[hook.SourceKind]; ok { + if err := verify(ctx, hook, callerIP); err != nil { + return nil, nil, err + } + } + + job, err := s.appService.UpdateAppContainersAsync(ctx, appID) + if err != nil { + return nil, nil, err + } + s.logger.InfoContext(ctx, "deploy hook triggered an update", "appID", appID, "hookID", hook.ID, "name", hook.Name, "callerIP", callerIP) + return job, toDomainDeployHook(hook), nil +} + +func toDomainDeployHook(h *db.DeployHook) *domain.DeployHook { + return &domain.DeployHook{ + ID: h.ID, + AppID: h.AppID, + Name: h.Name, + SourceKind: h.SourceKind, + CreatedAt: h.CreatedAt, + LastUsedAt: h.LastUsedAt, + LastUsedIP: h.LastUsedIP, + } +} diff --git a/web/src/features/app-details/components/AppTabContent.tsx b/web/src/features/app-details/components/AppTabContent.tsx index c5a89a81..cd7cd4dd 100644 --- a/web/src/features/app-details/components/AppTabContent.tsx +++ b/web/src/features/app-details/components/AppTabContent.tsx @@ -5,6 +5,7 @@ import { AppLogsPanel } from './AppLogsPanel' import AppOverview from './AppOverview' import CloudflareTab from './CloudflareTab' import ComposeEditor from './ComposeEditor' +import DeployHooksTab from './DeployHooksTab' import EnvironmentTab from './EnvironmentTab' import HistoryTab from './HistoryTab' import { ScheduleEditor } from './ScheduleEditor' @@ -29,6 +30,8 @@ function AppTabContent({ app, tab }: { app: App; tab: AppTab }) { missingNode('the compose editor') ))} {tab === 'environment' && (app.node_id ? : missingNode('the environment'))} + {tab === 'deploy' && + (app.node_id ? : missingNode('deploy hooks'))} {tab === 'logs' && (app.node_id ? : missingNode('logs'))} {tab === 'access' && diff --git a/web/src/features/app-details/components/DeployHooksTab.tsx b/web/src/features/app-details/components/DeployHooksTab.tsx new file mode 100644 index 00000000..d487a857 --- /dev/null +++ b/web/src/features/app-details/components/DeployHooksTab.tsx @@ -0,0 +1,261 @@ +import { useId, useState } from 'react' +import { AlertTriangle, Copy, KeyRound, Loader2, Trash2 } from 'lucide-react' +import { Button } from '@/shared/components/ui/Button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/Card' +import { CodeBlock } from '@/shared/components/ui/CodeBlock' +import ConfirmationDialog from '@/shared/components/ui/ConfirmationDialog' +import { ErrorState } from '@/shared/components/ui/ErrorState' +import { Input } from '@/shared/components/ui/Input' +import { Skeleton } from '@/shared/components/ui/Skeleton' +import { useToast } from '@/shared/components/ui/Toast' +import { useCopyToClipboard } from '@/shared/hooks/useCopyToClipboard' +import { formatAgo } from '@/shared/lib/attention' +import { describeError } from '@/shared/lib/errors' +import { type CreatedDeployHook, useCreateDeployHook, useDeployHooks, useRevokeDeployHook } from '@/shared/services/api' +import type { DeployHook } from '@/shared/types/api' +import { GITHUB_ACTIONS_DEPLOY_STEP, looksUnreachableFromHostedCI } from '../lib/deploy-hook-rules' + +// A repository variable to set alongside the SELFHOSTLY_DEPLOY_TOKEN secret, shown with its current +// value so it can be copied straight into GitHub's Settings > Secrets and variables > Actions. +function RepoVariableRow({ name, value, onCopy }: { name: string; value: string; onCopy: () => void }) { + return ( +
+
+ {name} + {value} +
+ +
+ ) +} + +function DeployHooksTab({ appId, nodeId }: { appId: string; nodeId: string }) { + const { data: hooks, isLoading, error } = useDeployHooks(appId, nodeId) + const createHook = useCreateDeployHook(appId, nodeId) + const revokeHook = useRevokeDeployHook(appId, nodeId) + const copy = useCopyToClipboard() + const { toast } = useToast() + + const nameId = useId() + const origin = window.location.origin + const originUnreachableFromCI = looksUnreachableFromHostedCI(window.location.hostname) + const [name, setName] = useState('') + const [created, setCreated] = useState(null) + const [pendingRevoke, setPendingRevoke] = useState(null) + + const handleCreate = () => { + const trimmed = name.trim() + if (!trimmed) return + createHook.mutate(trimmed, { + onSuccess: (hook) => { + setCreated(hook) + setName('') + }, + onError: (err) => { + toast.error('Could not create the hook', describeError(err)) + }, + }) + } + + const confirmRevoke = () => { + if (!pendingRevoke) return + revokeHook.mutate(pendingRevoke.id, { + onSuccess: () => { + toast.success('Hook revoked', `"${pendingRevoke.name}" no longer works`) + setPendingRevoke(null) + }, + onError: (err) => { + toast.error('Could not revoke the hook', describeError(err)) + }, + }) + } + + return ( +
+ + + Deploy hooks + + +

+ A deploy hook is a secret URL trigger for this app alone. Give it to a CI pipeline - a GitHub + Actions workflow, for example - so it can pull the latest image and restart this app right after + a build finishes, with nobody clicking Update by hand. Selfhostly never talks to your Git host: + the pipeline calls Selfhostly, not the other way around. +

+ + {isLoading && ( +
+ +
+ )} + {error && } + {hooks && hooks.length === 0 && ( +

No deploy hooks yet.

+ )} + {hooks && hooks.length > 0 && ( +
    + {hooks.map((hook) => ( +
  • +
    + {hook.name} + + Created {formatAgo(hook.created_at)} + {' ยท '} + {hook.last_used_at + ? `Last triggered ${formatAgo(hook.last_used_at)}${hook.last_used_ip ? ` from ${hook.last_used_ip}` : ''}` + : 'Never triggered'} + +
    + +
  • + ))} +
+ )} + +
{ + event.preventDefault() + handleCreate() + }} + > + +
+ setName(event.target.value)} + placeholder="GitHub Actions - main" + aria-describedby={`${nameId}-hint`} + className="w-full font-mono sm:max-w-xs" + /> + +
+

+ What calls it, so you can tell hooks apart later. +

+
+
+
+ + {created && ( + + + Copy this token now + + +

+ This is the only time it is shown. Store it as a secret in your CI pipeline, not in the + workflow file itself. +

+ +
+ +
+ +

+ In your repository's Settings > Secrets and variables > Actions, add the token above + as a secret named SELFHOSTLY_DEPLOY_TOKEN, and these three as variables: +

+ {originUnreachableFromCI && ( +
+
+ )} +
+ void copy(origin, 'SELFHOSTLY_URL copied to your clipboard')} + /> + void copy(appId, 'SELFHOSTLY_APP_ID copied to your clipboard')} + /> + void copy(nodeId, 'SELFHOSTLY_NODE_ID copied to your clipboard')} + /> +
+ +

+ Example step for a GitHub Actions workflow, after it builds and pushes the image: +

+ ({ text }))} + aria-label="Example GitHub Actions step" + /> +
+ +
+
+
+ )} + + !open && setPendingRevoke(null)} + title={`Revoke "${pendingRevoke?.name}"?`} + description="Anything still using this token stops being able to trigger an update for this app. This cannot be undone." + confirmText="Revoke" + cancelText="Cancel" + onConfirm={confirmRevoke} + isLoading={revokeHook.isPending} + variant="destructive" + confirmationText={pendingRevoke?.name} + /> +
+ ) +} + +export default DeployHooksTab diff --git a/web/src/features/app-details/index.tsx b/web/src/features/app-details/index.tsx index ad1120d8..9cb27cf3 100644 --- a/web/src/features/app-details/index.tsx +++ b/web/src/features/app-details/index.tsx @@ -11,7 +11,10 @@ import AppHeader from './components/AppHeader' import AppTabContent from './components/AppTabContent' import { useAppRecord } from './hooks/useAppRecord' -type TabType = Extract +type TabType = Extract< + AppTab, + 'overview' | 'config' | 'environment' | 'deploy' | 'logs' | 'access' | 'schedule' | 'history' +> function AppDetails() { const { id } = useParams<{ id: string }>() diff --git a/web/src/features/app-details/lib/deploy-hook-rules.ts b/web/src/features/app-details/lib/deploy-hook-rules.ts new file mode 100644 index 00000000..dedd323c --- /dev/null +++ b/web/src/features/app-details/lib/deploy-hook-rules.ts @@ -0,0 +1,30 @@ +// The GitHub Actions step for a deploy hook. It reads the instance's address, this app's id and the +// node it runs on from repository variables rather than baking them into the workflow file: the +// same step then works unedited if any of that ever changes (a new domain, a new tunnel, moving the +// app to a different node), and the file committed to the repo does not carry instance-specific +// data. node_id is required the same way it is on every other by-id request this app makes - a +// multi-node install's gateway routes purely on it, so a hook for an app on a secondary node has no +// other way to reach the node that actually holds it. Any CI that can run curl works the same way - +// this is one example to paste, not the only way to call the endpoint. +export const GITHUB_ACTIONS_DEPLOY_STEP = [ + '- name: Trigger Selfhostly deploy hook', + ' run: |', + ' curl --fail --silent --show-error \\', + ' --retry 3 --retry-delay 5 \\', + ' -X POST "${{ vars.SELFHOSTLY_URL }}/api/apps/${{ vars.SELFHOSTLY_APP_ID }}/deploy-trigger?node_id=${{ vars.SELFHOSTLY_NODE_ID }}" \\', + ' -H "Authorization: Bearer ${{ secrets.SELFHOSTLY_DEPLOY_TOKEN }}"', +].join('\n') + +// Loopback and private-network addresses: only reachable on this network, never from GitHub's +// hosted runners (a self-hosted runner on the same network is the exception). Flags a dashboard +// viewed through a LAN address or localhost, so that address is not copied into SELFHOSTLY_URL where +// it would silently never work from CI. +export function looksUnreachableFromHostedCI(hostname: string): boolean { + const h = hostname.toLowerCase() + if (h === 'localhost' || h.endsWith('.local')) return true + if (h === '::1' || h.startsWith('127.')) return true + if (h.startsWith('10.')) return true + if (h.startsWith('192.168.')) return true + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true + return false +} diff --git a/web/src/shared/lib/routes.ts b/web/src/shared/lib/routes.ts index 06f96301..a14148e5 100644 --- a/web/src/shared/lib/routes.ts +++ b/web/src/shared/lib/routes.ts @@ -14,7 +14,7 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ { from: '/monitoring', to: ROUTES.insights }, ] -const APP_TABS = ['overview', 'config', 'environment', 'logs', 'access', 'schedule', 'history'] as const +const APP_TABS = ['overview', 'config', 'environment', 'deploy', 'logs', 'access', 'schedule', 'history'] as const export type AppTab = (typeof APP_TABS)[number] const DEFAULT_APP_TAB: AppTab = 'overview' @@ -38,6 +38,7 @@ export const AVAILABLE_APP_TABS: readonly AppTab[] = [ 'overview', 'config', 'environment', + 'deploy', 'logs', 'access', 'schedule', @@ -53,6 +54,7 @@ export function resolveAppTab(value: string | null): AppTab { export const APP_TAB_LABELS: Record, string> = { config: 'Config', environment: 'Environment', + deploy: 'Deploy', logs: 'Logs', access: 'Access', schedule: 'Schedule', diff --git a/web/src/shared/services/api/apps.ts b/web/src/shared/services/api/apps.ts index 55efe341..e08dc8c5 100644 --- a/web/src/shared/services/api/apps.ts +++ b/web/src/shared/services/api/apps.ts @@ -10,6 +10,7 @@ import type { RollbackRequest, Job, JobResponse, + DeployHook, } from '../../types/api' // Apps API @@ -313,6 +314,50 @@ export function useRollbackToVersion(appId: string, nodeId: string) { }) } +// ============================================================================ +// Deploy hooks API +// ============================================================================ + +// The one-time response to creating a hook. The token is never part of DeployHook - it exists only +// here, and only until the caller navigates away. +export interface CreatedDeployHook extends DeployHook { + token: string +} + +export function useDeployHooks(appId: string, nodeId: string) { + return useQuery({ + queryKey: ['deploy-hooks', appId, nodeId], + queryFn: () => apiClient.get(`/api/apps/${appId}/deploy-hooks`, { node_id: nodeId }), + enabled: !!appId && !!nodeId, + }) +} + +export function useCreateDeployHook(appId: string, nodeId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (name: string) => + apiClient.post(`/api/apps/${appId}/deploy-hooks?node_id=${nodeId}`, { + name, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['deploy-hooks', appId, nodeId] }) + }, + }) +} + +export function useRevokeDeployHook(appId: string, nodeId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (hookId: string) => + apiClient.delete(`/api/apps/${appId}/deploy-hooks/${hookId}?node_id=${nodeId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['deploy-hooks', appId, nodeId] }) + }, + }) +} + // ============================================================================ // Job API // ============================================================================ diff --git a/web/src/shared/types/api.ts b/web/src/shared/types/api.ts index 1f21e476..9d2fb809 100644 --- a/web/src/shared/types/api.ts +++ b/web/src/shared/types/api.ts @@ -201,6 +201,18 @@ export interface RollbackRequest { change_reason?: string } +// A named, per-app secret an external pipeline presents to trigger a pull and restart. The token +// itself is never part of this shape - it exists only in the one-time response to creating a hook. +export interface DeployHook { + id: string + app_id: string + name: string + source_kind: string + created_at: string + last_used_at?: string | null + last_used_ip?: string +} + // System monitoring types export interface SystemStats { node_id: string diff --git a/web/tests/deploy-hook-rules.test.ts b/web/tests/deploy-hook-rules.test.ts new file mode 100644 index 00000000..3f7e74d9 --- /dev/null +++ b/web/tests/deploy-hook-rules.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + GITHUB_ACTIONS_DEPLOY_STEP, + looksUnreachableFromHostedCI, +} from '../src/features/app-details/lib/deploy-hook-rules.ts' + +test('the workflow step reads the instance, app id and node id from variables, never bakes them in', () => { + assert.match( + GITHUB_ACTIONS_DEPLOY_STEP, + /-X POST "\$\{\{ vars\.SELFHOSTLY_URL \}\}\/api\/apps\/\$\{\{ vars\.SELFHOSTLY_APP_ID \}\}\/deploy-trigger\?node_id=\$\{\{ vars\.SELFHOSTLY_NODE_ID \}\}"/, + ) + assert.match(GITHUB_ACTIONS_DEPLOY_STEP, /Authorization: Bearer \$\{\{ secrets\.SELFHOSTLY_DEPLOY_TOKEN \}\}/) + assert.doesNotMatch(GITHUB_ACTIONS_DEPLOY_STEP, /sfd_/) // never bakes in an actual token + assert.doesNotMatch(GITHUB_ACTIONS_DEPLOY_STEP, /:\/\/(?!\$)/) // no literal scheme://host baked in either +}) + +test('the step retries and fails the job on a bad response, instead of failing silently', () => { + assert.match(GITHUB_ACTIONS_DEPLOY_STEP, /--fail/) + assert.match(GITHUB_ACTIONS_DEPLOY_STEP, /--retry 3/) +}) + +test('loopback and private-network addresses are flagged as unreachable from hosted runners', () => { + for (const hostname of [ + 'localhost', + 'raspberrypi.local', + '127.0.0.1', + '10.0.0.5', + '192.168.1.50', + '172.16.0.1', + '172.31.255.255', + ]) { + assert.equal(looksUnreachableFromHostedCI(hostname), true, hostname) + } +}) + +test('a public domain is not flagged', () => { + for (const hostname of ['selfhostly.example.com', 'my-tunnel.trycloudflare.com', '203.0.113.5']) { + assert.equal(looksUnreachableFromHostedCI(hostname), false, hostname) + } + // 172.32.x and 172.15.x are outside the private 172.16-172.31 range + assert.equal(looksUnreachableFromHostedCI('172.32.0.1'), false) + assert.equal(looksUnreachableFromHostedCI('172.15.0.1'), false) +})