Files
timmy-config/docs/webhook-deployment.md
Alexander Payne 54a6def7e8 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
2026-04-30 10:03:57 -04:00

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.

  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:

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.