mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +00:00
21ec5e7e0a
* fix(webhooks): harden trigger response surface
Bundles six audit findings on the incoming-webhooks trigger path. All
changes preserve the documented happy path: a CI caller signing the exact
request body with the webhook secret still receives 202 Accepted.
* Uniform 404 on every unauthenticated rejection (missing webhook,
disabled webhook, non-paid tier, missing signature header, missing
raw body, signature mismatch). The four-way response surface previously
let an unauthenticated probe enumerate webhook ids and fingerprint the
instance's licence tier; callers now see one shape for any failed auth.
* Fail closed when express.json()'s verify callback did not capture the
raw request body. Previously the handler fell back to
JSON.stringify(req.body), which compares the HMAC against a
re-serialised payload that is not byte-equal to what the client signed.
* Pass the already-loaded webhook through to WebhookService.execute()
instead of re-fetching by id. Closes the delete-during-execution race
where an admin deletion between the trigger handler's load and the async
dispatch silently dropped the execution row. The webhook_executions
table has ON DELETE CASCADE, so recordExecution now wraps the insert in
try/catch and logs a warning when the FK constraint trips because the
parent webhook was deleted mid-flight.
* Redact bearer tokens, JWTs, URL credentials, and homedir paths from
error strings before persisting to webhook_executions.error. The
execution history is readable by any paid user via GET /webhooks/:id/
history; redactSensitiveText gains three home-directory patterns
(/home/<user>, /Users/<user>, <drive>:\Users\<user>) and now runs on
every error stored from this path.
* Cap webhook name at 100 characters on both POST and PUT, rejecting
non-string and oversized values with 400 before they reach the DB.
* Validate the body's action override against a typed allowlist
(isWebhookAction type guard) on the trigger endpoint, returning 400
before queueing execution. An unknown override no longer reaches
recordExecution as a stored failure row.
Tests updated to pass db.getWebhook(id)! instead of the raw id to the new
execute() signature. Docs at docs/features/webhooks.mdx updated to reflect
the new uniform 404 response, the new 400-on-invalid-action behaviour, and
a rewritten troubleshooting accordion that walks operators through every
cause of the uniform 404.
* test(webhooks): cover trigger handler auth, race, and redaction paths
Adds 21 vitest cases for the public webhook trigger handler and the
WebhookService.execute / recordExecution pipeline, plus 3 cases for the
new homedir patterns in redactSensitiveText.
webhooks-trigger.test.ts covers, per audit finding:
* M1 + H3 uniform 404: id unknown, webhook disabled, non-paid tier,
missing signature header, missing rawBody, sha1= prefix, malformed
hex signature, sig mismatch. Each asserts identical 404 body so a
future regression that re-introduces 401 / 403 / PAID_REQUIRED breaks
one of the 8 tests.
* Happy path: 202 with configured action, valid action override,
unknown action override returns 400 after auth succeeds (L2),
non-string action override returns 400.
* L1 name cap: POST and PUT both reject names over 100 chars and
non-string names; 100-char boundary still accepted; PUT allows
partial updates that omit name.
* M5 race: deleting the parent webhook before recordExecution runs no
longer crashes the async dispatch; the FK cascade is swallowed with
a console.warn, and a happy-path test pins the recordExecution row.
* M6 redaction: stubs ComposeService.runCommand to throw errors
containing a bearer token and a homedir path, then asserts the
persisted webhook_executions.error has both scrubbed.
safe-log.test.ts gains three unit tests pinning the new homedir
patterns in redactSensitiveText (Linux, macOS, Windows). The existing
credentials test is untouched.
Tests use prototype spies on FileSystemService and ComposeService (both
hand out a fresh instance per nodeId), so per-test mocks do not leak.
beforeEach restores all mocks and reapplies the LicenseService 'paid'
spy. Closes audit finding H2 (zero trigger-path test coverage).
* docs(webhooks): sync openapi spec with new trigger response surface
Brings docs/openapi.yaml in line with the behaviour changes from the
trigger hardening commit. Mintlify auto-generates the per-endpoint
reference pages from this spec, so the spec drift would surface as
wrong response codes in the public API reference.
POST /api/webhooks and PUT /api/webhooks/🆔
* name: maxLength 100 (matches MAX_WEBHOOK_NAME_LENGTH on the route).
* action enum: add git-pull (pre-existing omission; the route has
always accepted it).
* node_id: documented as an integer property (pre-existing omission).
POST /api/webhooks/:id/trigger:
* requestBody required: true (body is now mandatory; the H3
fail-closed branch rejects a missing rawBody).
* action override: enum restricted to the allowlist.
* 401 and 403 responses removed.
* 404 response: description rewritten to reflect uniform-404
behaviour; the body is { error: "Webhook not found or signature
invalid" } for every unauthenticated reject.
* 400 response added for an authenticated request whose action
override is not in the allowlist.
187 lines
10 KiB
Plaintext
187 lines
10 KiB
Plaintext
---
|
|
title: Webhooks
|
|
description: Trigger stack actions from CI/CD pipelines via HTTP webhooks with HMAC signature authentication.
|
|
---
|
|
|
|
<Note>
|
|
Webhooks require a **Skipper** or **Admiral** license. Creating, editing, and deleting webhooks is restricted to admin users; non-admins on a paid tier can view the list but cannot manage it.
|
|
</Note>
|
|
|
|
Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack, with no manual intervention required.
|
|
|
|
## How it works
|
|
|
|
1. You create a webhook in **Settings → Alerts → Webhooks**, targeting a specific stack and action.
|
|
2. Sencho generates a unique secret for HMAC-SHA256 signature validation.
|
|
3. Your CI/CD system sends a `POST` request to the trigger URL with the correct signature.
|
|
4. Sencho validates the signature and executes the action asynchronously.
|
|
|
|
## Creating a webhook
|
|
|
|
Open **Settings → Alerts → Webhooks** and click **Create webhook**. The page is part of the global settings and is reachable while the **Local** node is active in the node switcher; webhooks created from this page execute against the local Sencho instance.
|
|
|
|
<Frame>
|
|
<img src="/images/webhooks/webhooks-create-form.png" alt="New webhook form with Name, Stack, Node, and Action fields. The Action dropdown is open showing Deploy (down + up), Restart, Stop, Start, Pull and Update, and Git source sync." />
|
|
</Frame>
|
|
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| **Name** | A display name shown in the configured-webhooks list and execution history. |
|
|
| **Stack** | The target stack to act on. Picked from the stacks on the active node. |
|
|
| **Node** | Read-only. Reflects the currently active node; webhook execution is pinned to that node. |
|
|
| **Action** | One of the actions listed below. |
|
|
|
|
After you click **Create**, the new webhook's secret appears once in a green callout titled **Webhook created. Copy your secret now.** Copy it before dismissing the callout; once you navigate away, it cannot be retrieved. The configured-webhooks list always shows the secret in masked form (`********` plus the last four characters).
|
|
|
|
<Frame>
|
|
<img src="/images/webhooks/webhooks-secret-reveal.png" alt="Green success callout after creating a webhook, showing the full secret in a monospace block, a Copy button, and a Dismiss button. Below the callout, the configured-webhooks list shows the new webhook card with its trigger URL and masked secret." />
|
|
</Frame>
|
|
|
|
### Available actions
|
|
|
|
| Action | What it does |
|
|
|--------|-------------|
|
|
| **Deploy (down + up)** | Stops and redeploys the entire stack. |
|
|
| **Restart** | Restarts the stack's running containers. |
|
|
| **Stop** | Stops the stack. |
|
|
| **Start** | Starts a stopped stack. |
|
|
| **Pull & Update** | Pulls the latest images and recreates changed containers. |
|
|
| **Git source sync** | Pulls the latest commit from the stack's Git source, then deploys. Requires a [Git source](/features/git-sources) attached to the stack. |
|
|
|
|
## Managing webhooks
|
|
|
|
Each configured webhook renders as a card showing the webhook name, an **action** badge, a **stack** badge, a **node** badge, an enable toggle, and a delete button. Below the header are a labeled **Trigger URL** row with a copy button, a labeled **Secret** row showing the masked secret, and a **Recent executions** disclosure that expands inline.
|
|
|
|
The masthead above the section reports total **Webhooks** and how many are **Enabled**.
|
|
|
|
<Frame>
|
|
<img src="/images/webhooks/webhooks-settings.png" alt="Configured webhooks section with two webhook cards. The first card has Recent executions expanded, showing several deploy entries with timestamps and durations and one restart entry that failed with an error message. The second card is collapsed. The masthead at the top shows WEBHOOKS 2 and ENABLED 2." />
|
|
</Frame>
|
|
|
|
From a webhook card you can:
|
|
|
|
- Toggle the webhook on or off with the **On / Off** switch.
|
|
- Delete the webhook with the trash icon.
|
|
- Copy the trigger URL to the clipboard with the copy button.
|
|
- Expand **Recent executions** to view the inline history.
|
|
|
|
The card has no in-place edit affordance: to change the name, stack, or action, delete the webhook and create a new one. The signing secret is bound to a single webhook for its lifetime; deleting and recreating rotates it.
|
|
|
|
## Triggering a webhook
|
|
|
|
Send a `POST` request to the trigger URL shown on the webhook card. Include an HMAC-SHA256 signature in the `X-Webhook-Signature` header:
|
|
|
|
```
|
|
POST /api/webhooks/{id}/trigger
|
|
Content-Type: application/json
|
|
X-Webhook-Signature: sha256={hmac}
|
|
```
|
|
|
|
The signature is computed as `HMAC-SHA256(raw_request_body, webhook_secret)` over the exact bytes of the request body (UTF-8 if you send JSON) and sent with a `sha256=` prefix. Sencho uses a constant-time comparison.
|
|
|
|
### Example with curl
|
|
|
|
```bash
|
|
SECRET="your-webhook-secret"
|
|
BODY='{}'
|
|
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
|
|
|
|
curl -X POST https://your-sencho.example.com/api/webhooks/3/trigger \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
|
|
-d "$BODY"
|
|
```
|
|
|
|
### Overriding the action
|
|
|
|
By default, the webhook executes the action configured at creation time. You can override it per request by including an `action` field in the body:
|
|
|
|
```json
|
|
{ "action": "restart" }
|
|
```
|
|
|
|
Valid override values are: `deploy`, `restart`, `stop`, `start`, `pull`, `git-pull`. An unknown value is rejected with `400 Bad Request` before execution is queued.
|
|
|
|
### Responses
|
|
|
|
| Status | Body | Meaning |
|
|
|--------|------|---------|
|
|
| `202 Accepted` | `{ "message": "Webhook accepted", "action": "deploy" }` | Signature is valid; the action is now running asynchronously. A 202 means accepted, not finished. |
|
|
| `400 Bad Request` | `{ "error": "action must be one of: deploy, restart, stop, start, pull, git-pull" }` | The request authenticated successfully but the body's `action` override was not in the allowlist. |
|
|
| `404 Not Found` | `{ "error": "Webhook not found or signature invalid" }` | The webhook id is unknown, the webhook is disabled, the signature header is missing, the request body was empty, or the signature did not match. Sencho returns the same response for every unauthenticated case so callers cannot probe ids or licence state. |
|
|
|
|
## CI/CD integration examples
|
|
|
|
### GitHub Actions
|
|
|
|
```yaml
|
|
- name: Deploy via Sencho
|
|
run: |
|
|
BODY='{}'
|
|
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "${{ secrets.SENCHO_WEBHOOK_SECRET }}" | cut -d' ' -f2)
|
|
curl -X POST "${{ secrets.SENCHO_URL }}/api/webhooks/${{ secrets.SENCHO_WEBHOOK_ID }}/trigger" \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
|
|
-d "$BODY"
|
|
```
|
|
|
|
### GitLab CI
|
|
|
|
```yaml
|
|
deploy:
|
|
stage: deploy
|
|
script:
|
|
- BODY='{}'
|
|
- SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SENCHO_WEBHOOK_SECRET" | cut -d' ' -f2)
|
|
- 'curl -X POST "$SENCHO_URL/api/webhooks/$SENCHO_WEBHOOK_ID/trigger"
|
|
-H "Content-Type: application/json"
|
|
-H "X-Webhook-Signature: sha256=$SIGNATURE"
|
|
-d "$BODY"'
|
|
```
|
|
|
|
## Execution history
|
|
|
|
Click **Recent executions** on any webhook card to expand its inline history. Each row shows:
|
|
|
|
- A **status** icon (green check for success, red X for failure).
|
|
- The **action** that ran (the configured action, or the override if one was supplied).
|
|
- A localized **timestamp**.
|
|
- The **duration** in seconds when available.
|
|
- A truncated **error** message on failures; hover the row to see the full message in a tooltip.
|
|
|
|
Sencho retains the last 100 executions per webhook and surfaces the 20 most recent in the inline view.
|
|
|
|
## Security
|
|
|
|
- Webhook secrets are returned only once, at the moment of creation. Every subsequent view of the webhook (in the list, in the API, anywhere) shows the masked form `********` plus the last four characters.
|
|
- Trigger endpoints are public in the sense that they do not require a Sencho session cookie, but every request must carry a valid HMAC-SHA256 signature; signatures are compared with a constant-time check.
|
|
- Each webhook targets a single stack and runs a single action, optionally narrowed by the supplied override. There is no path to execute arbitrary commands through a webhook.
|
|
- Webhook execution is pinned to the node selected at creation time.
|
|
|
|
## Troubleshooting
|
|
|
|
<AccordionGroup>
|
|
<Accordion title="I don't see Webhooks in Settings.">
|
|
The page requires a **Skipper** or **Admiral** license. If you are on a paid tier but the node switcher in the top-left shows a remote node, switch to **Local** to reveal the page.
|
|
</Accordion>
|
|
|
|
<Accordion title="My trigger returns 404 'Webhook not found or signature invalid'.">
|
|
The trigger endpoint returns the same 404 for every unauthenticated case, so the response alone will not tell you which check failed. Work through this checklist:
|
|
|
|
- The id in the URL is wrong. Copy the trigger URL straight from the webhook card in **Settings → Alerts → Webhooks**.
|
|
- The webhook's enable toggle is off. Check the **On / Off** switch on the card.
|
|
- The request omitted the `X-Webhook-Signature` header. Confirm the full value, including the `sha256=` prefix; the header value is `sha256={hex}`.
|
|
- The request had no body. Send at least `{}` and sign that exact byte sequence.
|
|
- The signature was computed over different bytes from what the request carried. Sign the exact string you send. Common offenders: the CI step JSON-encodes the body before signing but pipes a different body to curl, or the shell adds a trailing newline (use `echo -n` rather than `echo`).
|
|
- The webhook secret used to sign does not match the secret stored in Sencho. The secret is shown once at creation; if you lost it, delete the webhook and create a new one.
|
|
</Accordion>
|
|
|
|
<Accordion title="The action returns 202 but the stack does not change.">
|
|
A 202 means the action was accepted, not that it produced a visible change. **Pull & Update** is a no-op if the registry has no newer image for any service in the stack; **Start** is a no-op on an already-running stack. Check the stack's **Activity** sheet to confirm what actually ran, and the webhook's **Recent executions** for the action's status and duration.
|
|
</Accordion>
|
|
|
|
<Accordion title="I cannot create a Git source sync webhook for this stack.">
|
|
The **Git source sync** action can only be selected for a stack that already has a [Git source](/features/git-sources) attached. If you try to create one without it, the form returns a validation error. Attach a Git source to the stack in its Compose editor, then create the webhook.
|
|
</Accordion>
|
|
</AccordionGroup>
|