- 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
162 lines
6.4 KiB
Markdown
162 lines
6.4 KiB
Markdown
# 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`.
|