- 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
6.4 KiB
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:
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)
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:
[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:
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.
- In Gitea: Repository → Settings → Webhooks → Add Webhook
- Type:
Gitea - Target URL:
http://<receiver-host>:9000/webhooks/gitea - HTTP method:
POST - Content type:
application/json - Secret: paste the same value as
GITEA_WEBHOOK_SECRET - Triggers:
Push events,Pull request events(optionallyIssues) - Active: ✓
- Add webhook
Verify with:
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
# 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
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.