# 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="" ``` For Hermes agents, add to `~/.hermes/.env`: ``` GITEA_WEBHOOK_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= 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://: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`.