feat(webhook): authenticated webhook runner with allowlists, signature verification, idempotent logging

- Rewrite scripts/gitea_webhook_handler.py as HTTP server with HMAC-SHA256 auth
- Add config/webhook.yaml defining allowed repos/events/branches/actions
- Implement dispatch_push calling ansible/scripts/deploy_on_webhook.sh safely
- SQLite logging table with delivery_id dedup for replay safety
- Add tests/test_gitea_webhook_handler.py covering push/PR/signature/idempotency
- Add docs/webhook-deployment.md with security model, ops, and #288 alignment

Closes #436
This commit is contained in:
Alexander Payne
2026-04-30 10:03:57 -04:00
parent ba4220d5ed
commit 54a6def7e8
4 changed files with 859 additions and 62 deletions

161
docs/webhook-deployment.md Normal file
View File

@@ -0,0 +1,161 @@
# Webhook Deployment — Gitea → Authenticated Runner
**Related:** #288 (webhook creation), #432 (hardening epic), #436 (this work)
## Overview
The authenticated webhook runner (`scripts/gitea_webhook_handler.py`) replaces
the print-only payload parser with a production-hardened receiver:
- **HMAC-SHA256 signature verification** (rejects unauthenticated requests)
- **Config-driven allowlists** (repos, events, branches, PR actions)
- **Safe action dispatch** — only pre-approved scripts run, never arbitrary commands
- **Idempotent event logging** — SQLite-backed replay-safe store
- **Structured JSON logs** — auditable acceptance/rejection decisions
## Security Model
| Threat | Mitigation |
|----------------------------------|-----------------------------------------------------------------------------|
| Spoofed payload (no secret) | `X-Gitea-Signature` HMAC verification (`require_signature: true`) |
| Payload field injection | No direct interpolation — actions hardcoded; branch matched against set |
| Event replay | `guid` dedup in SQLite `webhook_events` table |
| Privilege escalation | Deploy script runs as invoking user; no `sudo` from webhook context |
| Information leakage | Minimal error detail in HTTP 4xx responses; full details in logs only |
## Configuration
### 1. `config/webhook.yaml`
Defines allowlists. Commit this file — it contains no secrets:
allowed_repos: [timmy-config]
allowed_events: [push, pull_request, issues]
allowed_branches: [refs/heads/main, refs/heads/master]
allowed_pr_actions: [opened, closed, reopened, synchronized]
require_signature: true
deploy_script: ansible/scripts/deploy_on_webhook.sh
### 2. Environment — `GITEA_WEBHOOK_SECRET`
Set this on the webhook receiver host:
```bash
export GITEA_WEBHOOK_SECRET="<the-shared-secret-from-gitea>"
```
For Hermes agents, add to `~/.hermes/.env`:
```
GITEA_WEBHOOK_SECRET=<same-secret>
```
**Important:** The secret is configured when creating the Gitea webhook.
Store it in 1Password or similar. Never commit it.
### 3. Deploy script
`ansible/scripts/deploy_on_webhook.sh` — runs `ansible-pull` to apply
timmy-config as a sidecar overlay. It is:
- Lock-protected (prevents concurrent runs)
- Logging to `/var/log/ansible/webhook-deploy.log`
- Safe — no shell interpolation from webhook payload
## Server Operation
### Manual start (development)
```bash
export GITEA_WEBHOOK_SECRET=$(cat ~/.config/gitea/webhook-secret)
python3 scripts/gitea_webhook_handler.py --host 127.0.0.1 --port 9000
```
### systemd unit (production)
Place `/etc/systemd/system/timmy-webhook.service`:
```ini
[Unit]
Description=Timmy Gitea Webhook Handler
After=network.target
[Service]
Type=simple
User=alex
WorkingDirectory=/Users/alex/timmy-config
Environment=GITEA_WEBHOOK_SECRET=<secret>
ExecStart=/usr/bin/env python3 /Users/alex/timmy-config/scripts/gitea_webhook_handler.py --port 9000
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Then:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now timmy-webhook
sudo systemctl status timmy-webhook
```
Logs: `journalctl -u timmy-webhook -f`
## Gitea Webhook Creation (aligns with #288)
**Admin action — required once per repo.**
1. In Gitea: Repository → Settings → Webhooks → Add Webhook
2. Type: `Gitea`
3. Target URL: `http://<receiver-host>:9000/webhooks/gitea`
4. HTTP method: `POST`
5. Content type: `application/json`
6. Secret: paste the same value as `GITEA_WEBHOOK_SECRET`
7. Triggers: `Push events`, `Pull request events` (optionally `Issues`)
8. Active: ✓
9. Add webhook
Verify with:
```bash
curl -X POST http://localhost:9000/webhooks/gitea \
-H "X-Gitea-Signature: sha256=invalid" \
-H "Content-Type: application/json" \
-d '{"test":"bad"}' -w "\n%{http_code}\n"
# → 401
```
## Verification
### Smoke test — valid push
```bash
# Simulate a push event (normally Gitea does this after webhook creation)
curl -X POST http://localhost:9000/webhooks/gitea \
-H "X-Gitea-Signature: $(printf '{"event":"push","repository":{"name":"timmy-config"},"ref":"refs/heads/main"}' | openssl dgst -sha256 -hmac "$GITEA_WEBHOOK_SECRET" -r | awk '{print $2}')" \
-H "Content-Type: application/json" \
-d '{"event":"push","repository":{"name":"timmy-config","owner":{"login":"allegro"}},"ref":"refs/heads/main","commits":[{"id":"abc123"}],"sender":{"username":"allegro"}}'
# → {"status":"deploy triggered successfully"}
```
### Idempotency check — repeat the same curl
The second call returns `{"status":"already processed"}` and logs a duplicate.
### DB audit
```bash
sqlite3 logs/webhook_events.sqlite "SELECT delivery_id, event_type, verdict, received_at FROM webhook_events ORDER BY received_at DESC LIMIT 10;"
```
## Logs
- **Event DB:** `logs/webhook_events.sqlite` — permanent, queryable audit log
- **Deploy log:** `/var/log/ansible/webhook-deploy.log` — ansible-pull output
- **Service logs:** `journalctl -u timmy-webhook -f`
## Troubleshooting
| Symptom | Likely cause & fix |
|----------------------------------------------|------------------------------------------------------------------------|
| HTTP 401 invalid signature | `GITEA_WEBHOOK_SECRET` mismatch. Re-sync env var and Gitea webhook. |
| HTTP 403 repo not in allowlist | Add repo name to `config/webhook.yaml`. |
| HTTP 403 branch not allowed | Verify `refs/heads/main` spelling in allowlist. |
| No response / connection refused | Server not running? `systemctl status timmy-webhook`. |
| Deploy script not found | Check `deploy_script` path in config; ensure file exists & executable.|
| Duplicate delivery IDs in DB after restart | SQLite DB is the source of truth — restart clears in-memory cache but DB persists. |
## Alignment with #288
This runner is the **receiver endpoint** that #288's webhook configuration
points to. #288 handles webhook *creation* on Gitea repos; this handler
handles the *execution* path safely. Deploy the server first, then use #288
workflow to wire each Timmy_Foundation repository to `http://host:9000/webhooks/gitea`.