#!/bin/bash set -euo pipefail # ============================================================================= # Webhook Secret Rotation Script (v3) # Rotates the HMAC-SHA256 secret used by nginx njs to verify Gitea webhooks. # Runs monthly via cron: 0 3 1 * * (1st of month, 3am UTC) # # Flow: # 1. Generate new secret # 2. Update ALL Gitea webhooks with new secret (before nginx) # 3. Verify at least one webhook succeeds # 4. Write new secret to nginx file + container copy # 5. Reload nginx # 6. If any step fails, rollback everything # ============================================================================= SECRET_FILE="/etc/nginx/gitea-webhook-secret" GITEA_API="@@GITEA_API@@/api/v1" GITEA_TOKEN_FILE="/etc/nginx/gitea-admin-token" BACKUP_DIR="/var/lib/webhook-security/secret-backups" LOG_TAG="webhook-secret-rotation" # Container path on the HOST filesystem (not inside the container) CONTAINER_SECRET_FILE="" WEBHOOK_URL_MATCH="@@WEBHOOK_URL_MATCH@@" NTFY_TOPIC="" # Only scan repos from these owners (optimization: skip 100+ sneak/* repos) SCAN_OWNERS="@@SCAN_OWNERS@@" DRY_RUN=false if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=true echo "[DRY RUN] No changes will be made" fi log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1"; logger -t "$LOG_TAG" "$1" 2>/dev/null || true; } notify_failure() { curl -sf -H "Title: Webhook Secret Rotation FAILED" -H "Priority: urgent" \ -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true } die() { log "FATAL: $1" notify_failure "$1" exit 1 } # --- Preflight checks --- [ -f "$GITEA_TOKEN_FILE" ] || die "Gitea admin token file not found: $GITEA_TOKEN_FILE" GITEA_TOKEN=$(tr -d '[:space:]' < "$GITEA_TOKEN_FILE") [ -n "$GITEA_TOKEN" ] || die "Gitea admin token file is empty" [ -f "$SECRET_FILE" ] || die "Current secret file not found: $SECRET_FILE" OLD_SECRET=$(tr -d '[:space:]' < "$SECRET_FILE") [ -n "$OLD_SECRET" ] || die "Current secret file is empty" command -v jq >/dev/null 2>&1 || die "jq not found - required for JSON processing" command -v openssl >/dev/null 2>&1 || die "openssl not found - required for secret generation" log "Starting webhook secret rotation" # --- Dynamic repo discovery (filtered by owner) --- log "Discovering repos with our webhooks..." declare -A REPO_HOOKS # repo -> space-separated hook IDs for OWNER in $SCAN_OWNERS; do page=1 while true; do REPOS_JSON=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/search?limit=50&page=$page&owner=$OWNER" 2>/dev/null || echo '{"data":[]}') PAGE_REPOS=$(echo "$REPOS_JSON" | jq -r '.data[]?.full_name // empty' 2>/dev/null || true) [ -n "$PAGE_REPOS" ] || break while IFS= read -r REPO; do [ -n "$REPO" ] || continue HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \ jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true) if [ -n "$HOOK_IDS" ]; then REPO_HOOKS["$REPO"]="$HOOK_IDS" log " Found: $REPO (hooks: $(echo $HOOK_IDS | tr '\n' ' '))" fi done <<< "$PAGE_REPOS" page=$((page + 1)) done done REPO_COUNT=${#REPO_HOOKS[@]} log "Found $REPO_COUNT repos with our webhooks" if [ "$REPO_COUNT" -eq 0 ]; then die "No repos found with our webhooks - something is wrong with discovery" fi # --- Generate new secret --- NEW_SECRET=$(openssl rand -hex 32) if $DRY_RUN; then log "[DRY RUN] Would update $REPO_COUNT repos" log "[DRY RUN] Would write new secret to $SECRET_FILE" log "[DRY RUN] Would update container secret at $CONTAINER_SECRET_FILE" log "[DRY RUN] Would reload nginx" for REPO in "${!REPO_HOOKS[@]}"; do log "[DRY RUN] $REPO: hooks ${REPO_HOOKS[$REPO]}" done exit 0 fi # --- Backup old secret --- mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" BACKUP_FILE="$BACKUP_DIR/secret-$(date -u '+%Y%m%d-%H%M%S').bak" cp "$SECRET_FILE" "$BACKUP_FILE" chmod 600 "$BACKUP_FILE" log "Backed up old secret to $BACKUP_FILE" ls -t "$BACKUP_DIR"/secret-*.bak 2>/dev/null | tail -n +7 | xargs -r rm -f # --- Phase 1: Update ALL Gitea webhooks with new secret FIRST --- # This is done BEFORE writing the new secret to nginx. If this phase fails, # we rollback the webhooks to the old secret. nginx never sees the new secret. log "Phase 1: Updating Gitea webhooks with new secret..." UPDATED=0 FAILED=0 UPDATED_REPOS=() # Track which repos were updated (for rollback) FAILED_REPOS=() for REPO in "${!REPO_HOOKS[@]}"; do HOOK_IDS="${REPO_HOOKS[$REPO]}" log " Updating $REPO..." REPO_OK=true while IFS= read -r HOOK_ID; do [ -n "$HOOK_ID" ] || continue # Get current hook events EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks/$HOOK_ID" 2>/dev/null | \ jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]') # Delete old hook DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks/$HOOK_ID") if [ "$DEL_CODE" != "204" ]; then log " WARNING: Delete hook #$HOOK_ID returned HTTP $DEL_CODE" REPO_OK=false continue fi # Create new hook with new secret CREATE_BODY=$(jq -n \ --arg secret "$NEW_SECRET" \ --arg url "https://$WEBHOOK_URL_MATCH" \ --argjson events "$EVENTS" \ '{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}') CREATE_RESULT=$(curl -sf -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d "$CREATE_BODY" \ "$GITEA_API/repos/$REPO/hooks" 2>/dev/null || echo "FAIL") NEW_ID=$(echo "$CREATE_RESULT" | jq -r '.id // empty' 2>/dev/null || true) if [ -n "$NEW_ID" ]; then log " Created hook #$NEW_ID (replaced #$HOOK_ID)" UPDATED=$((UPDATED + 1)) else log " FAILED to create replacement for #$HOOK_ID" REPO_OK=false fi done <<< "$HOOK_IDS" if $REPO_OK; then UPDATED_REPOS+=("$REPO") else FAILED_REPOS+=("$REPO") FAILED=$((FAILED + 1)) fi done log "Phase 1 complete: $UPDATED hooks updated, $FAILED repos with failures" # --- Rollback if any failures --- if [ "$FAILED" -gt 0 ]; then log "ROLLING BACK: $FAILED repo(s) failed webhook update" log "Failed repos: ${FAILED_REPOS[*]}" # Rollback successful repos back to old secret for REPO in "${UPDATED_REPOS[@]}"; do log " Rolling back $REPO to old secret..." HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \ jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true) while IFS= read -r HID; do [ -n "$HID" ] || continue EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks/$HID" 2>/dev/null | \ jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]') curl -s -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_API/repos/$REPO/hooks/$HID" ROLLBACK_BODY=$(jq -n \ --arg secret "$OLD_SECRET" \ --arg url "https://$WEBHOOK_URL_MATCH" \ --argjson events "$EVENTS" \ '{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}') curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d "$ROLLBACK_BODY" \ "$GITEA_API/repos/$REPO/hooks" > /dev/null 2>&1 || true done <<< "$HOOK_IDS" done die "Rotation aborted: $FAILED repo(s) failed. All webhooks rolled back to old secret. Failed: ${FAILED_REPOS[*]}" fi # --- Phase 2: Write new secret to nginx + container --- log "Phase 2: Writing new secret to nginx..." printf '%s' "$NEW_SECRET" > "$SECRET_FILE" chown root:www-data "$SECRET_FILE" chmod 640 "$SECRET_FILE" log "Wrote new secret to $SECRET_FILE" # Update container-local copy if [ -d "$(dirname "$CONTAINER_SECRET_FILE")" ]; then printf '%s' "$NEW_SECRET" > "$CONTAINER_SECRET_FILE" chmod 600 "$CONTAINER_SECRET_FILE" log "Updated container secret at $CONTAINER_SECRET_FILE" else log "WARNING: Container secret directory not found at $(dirname "$CONTAINER_SECRET_FILE")" fi # --- Phase 3: Reload nginx --- log "Phase 3: Reloading nginx..." if ! nginx -t 2>/dev/null; then # Rollback nginx secret cp "$BACKUP_FILE" "$SECRET_FILE" chown root:www-data "$SECRET_FILE" chmod 640 "$SECRET_FILE" die "nginx -t failed after writing new secret. Rolled back secret file. Webhooks still have NEW secret — manual intervention needed!" fi nginx -s reload log "nginx reloaded successfully" # --- Done --- log "Rotation complete. Updated: $UPDATED hooks across ${#UPDATED_REPOS[@]} repos. Failed: 0"