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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
143 changes: 143 additions & 0 deletions docs/design/app-deploy-hooks.md
Original file line number Diff line number Diff line change
@@ -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:<name>` 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 <token>`) | 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.
21 changes: 20 additions & 1 deletion internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions internal/db/deploy_hooks.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading