openclaw_oauth_sync/setup.sh
sol 2db7d7d90a feat: merge Gitea webhook security into setup wizard (issue #2)
Integrates the 5-layer Gitea webhook security system from
sol/clawgravity-hook-security (v2.0) into the setup wizard.

## What's added

### New files (from clawgravity-hook-security v2.0)
- scripts/webhook-security/gitea-hmac-verify.js    -- njs HMAC-SHA256 module
- scripts/webhook-security/gitea-approve-repo       -- allowlist helper
- scripts/webhook-security/rotate-webhook-secret.sh -- monthly secret rotation (templated)
- scripts/webhook-security/webhook-audit-alert.sh   -- daily audit summaries (templated)
- scripts/webhook-security/ntfy-blocked-pickup.sh   -- blocked webhook alerts (templated)
- templates/webhook-security/nginx-site.conf.example
- templates/webhook-security/nginx.conf.example
- templates/webhook-security/gitea-repo-allowlist.json.example
- docs/WEBHOOK-SECURITY.md   -- full documentation
- docs/SECURITY-AUDIT.md     -- 35-case test matrix
- tests/test-webhook-security.sh  -- 48 offline tests

### Modified files
- setup.sh: Step 11 (webhook security wizard with 6 sub-sections)
- scripts/uninstall.sh: webhook security cleanup section
- README.md: Webhook Security section after Quick Start
- Makefile: test target now runs test-webhook-security.sh
- .secret-scan-allowlist: allowlist docs/SECURITY-AUDIT.md (test fixture)

## Security layers
1. IP allowlisting (nginx)
2. Rate limiting 10 req/s burst 20 (nginx)
3. Payload size 1MB (nginx)
4. HMAC-SHA256 signature verification (njs)
5. Per-repository allowlist (njs)

## make check
- prettier: PASS
- secret-scan: PASS
- tests: 48/48 PASS

Closes #2
2026-03-01 08:43:02 +00:00

1460 lines
54 KiB
Bash

#!/bin/bash
# ============================================================================
# OAuth Fix for OpenClaw — Interactive Setup Wizard
# ============================================================================
# Configures automatic Anthropic OAuth token refresh for OpenClaw.
# Detects paths, installs the sync service, and configures the Anthropic model.
#
# Usage:
# ./setup.sh # Interactive mode
# ./setup.sh --uninstall # Remove everything
# ============================================================================
set -euo pipefail
VERSION="1.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
header() { echo -e "\n${BOLD}${CYAN}━━━ $* ━━━${NC}\n"; }
ask() {
local prompt="$1"
local default="${2:-}"
local result
if [ -n "$default" ]; then
read -rp "$(echo -e "${BOLD}$prompt${NC} [$default]: ")" result
echo "${result:-$default}"
else
read -rp "$(echo -e "${BOLD}$prompt${NC}: ")" result
echo "$result"
fi
}
confirm() {
local prompt="$1"
local default="${2:-Y}"
local result
read -rp "$(echo -e "${BOLD}$prompt${NC} [${default}]: ")" result
result="${result:-$default}"
[[ "$result" =~ ^[Yy] ]]
}
# --- Uninstall ---
if [ "${1:-}" = "--uninstall" ]; then
bash "$SCRIPT_DIR/scripts/uninstall.sh"
exit $?
fi
# ============================================================================
# BANNER
# ============================================================================
echo ""
echo -e "${BOLD}${CYAN}"
echo " ╔══════════════════════════════════════════════════════╗"
echo " ║ OAuth Fix for OpenClaw + Claude Max ║"
echo " ║ Automatic Anthropic Token Refresh ║"
echo " ║ v${VERSION}"
echo " ║ ║"
echo " ║ Created by ROOH — <project-url> ║"
echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e "${DIM} Keeps your Anthropic OAuth tokens fresh by syncing"
echo -e " Claude Code CLI's auto-refreshed credentials to OpenClaw.${NC}"
echo ""
# ============================================================================
# STEP 1: Prerequisites
# ============================================================================
header "Step 1: Checking Prerequisites"
MISSING=0
# --- Required (cannot be installed by wizard) ---
for cmd in systemctl docker; do
if command -v "$cmd" &>/dev/null; then
success "$cmd found"
else
error "$cmd not found — required, cannot be installed by this wizard"
MISSING=$((MISSING + 1))
fi
done
# Check docker compose (v2)
if docker compose version &>/dev/null; then
success "docker compose found ($(docker compose version --short 2>/dev/null || echo 'v2'))"
else
error "docker compose v2 not found — required"
MISSING=$((MISSING + 1))
fi
# --- python3 (offer to install) ---
if command -v python3 &>/dev/null; then
success "python3 found"
else
warn "python3 not found (required for JSON processing)"
if confirm " Install python3 now?" "Y"; then
apt-get install -y python3 2>&1 | tail -3
if command -v python3 &>/dev/null; then
success "python3 installed"
else
error "python3 installation failed"
MISSING=$((MISSING + 1))
fi
else
error "python3 is required — install it manually and re-run"
MISSING=$((MISSING + 1))
fi
fi
# --- curl (offer to install) ---
if command -v curl &>/dev/null; then
success "curl found"
else
warn "curl not found"
if confirm " Install curl now?" "Y"; then
apt-get install -y curl 2>&1 | tail -3
if command -v curl &>/dev/null; then
success "curl installed"
else
error "curl installation failed"
MISSING=$((MISSING + 1))
fi
else
error "curl is required — install it manually and re-run"
MISSING=$((MISSING + 1))
fi
fi
# --- inotifywait (offer to install, optional) ---
if command -v inotifywait &>/dev/null; then
success "inotifywait found"
USE_INOTIFY=true
else
warn "inotifywait not found (inotify-tools package)"
echo -e " ${DIM}Provides real-time credential sync. Without it, a 6-hour timer is used instead.${NC}"
if confirm " Install inotify-tools now?" "Y"; then
apt-get install -y inotify-tools 2>&1 | tail -3
if command -v inotifywait &>/dev/null; then
success "inotifywait installed"
USE_INOTIFY=true
else
warn "Installation failed. Will use timer-based fallback."
USE_INOTIFY=false
fi
else
warn "Will use timer-based fallback (6-hour refresh cycle)"
USE_INOTIFY=false
fi
fi
if [ "$MISSING" -gt 0 ]; then
error "$MISSING prerequisite(s) missing. Please install them and re-run."
exit 1
fi
# ============================================================================
# STEP 2: Detect OpenClaw Installation
# ============================================================================
header "Step 2: Detecting OpenClaw Installation"
# Find openclaw.json
OPENCLAW_CONFIG_DIR=""
for path in \
"${OPENCLAW_CONFIG_DIR:-}" \
/root/.openclaw \
/home/*/.openclaw; do
if [ -f "$path/openclaw.json" ] 2>/dev/null; then
OPENCLAW_CONFIG_DIR="$path"
break
fi
done
if [ -z "$OPENCLAW_CONFIG_DIR" ]; then
# Try docker inspect
GATEWAY=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'openclaw.*gateway' | head -1)
if [ -n "$GATEWAY" ]; then
OPENCLAW_CONFIG_DIR=$(docker inspect "$GATEWAY" --format '{{range .Mounts}}{{if eq .Destination "/home/node/.openclaw"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
fi
fi
if [ -z "$OPENCLAW_CONFIG_DIR" ] || [ ! -f "$OPENCLAW_CONFIG_DIR/openclaw.json" ]; then
error "Cannot find OpenClaw installation"
OPENCLAW_CONFIG_DIR=$(ask "Enter OpenClaw config directory (contains openclaw.json)")
if [ ! -f "$OPENCLAW_CONFIG_DIR/openclaw.json" ]; then
error "openclaw.json not found in $OPENCLAW_CONFIG_DIR"
exit 1
fi
fi
success "Config: $OPENCLAW_CONFIG_DIR/openclaw.json"
# Find docker-compose directory
COMPOSE_DIR=""
for path in \
/root/openclaw \
"$(dirname "$OPENCLAW_CONFIG_DIR")/openclaw" \
/opt/openclaw; do
if [ -f "$path/docker-compose.yml" ] || [ -f "$path/docker-compose.yaml" ] || [ -f "$path/compose.yml" ]; then
COMPOSE_DIR="$path"
break
fi
done
if [ -z "$COMPOSE_DIR" ]; then
COMPOSE_DIR=$(ask "Enter OpenClaw docker-compose directory" "/root/openclaw")
fi
success "Compose: $COMPOSE_DIR"
# Find .env file
ENV_FILE="$COMPOSE_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
warn ".env not found at $ENV_FILE"
ENV_FILE=$(ask "Enter .env file path" "$COMPOSE_DIR/.env")
fi
success "Env: $ENV_FILE"
# Find gateway container
GATEWAY_CONTAINER=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'openclaw.*gateway' | head -1)
if [ -z "$GATEWAY_CONTAINER" ]; then
GATEWAY_CONTAINER=$(ask "Enter gateway container name" "openclaw-openclaw-gateway-1")
fi
success "Gateway: $GATEWAY_CONTAINER"
# Derive oauth.json path
OPENCLAW_OAUTH_FILE="$OPENCLAW_CONFIG_DIR/credentials/oauth.json"
success "OAuth file: $OPENCLAW_OAUTH_FILE"
# ============================================================================
# STEP 3: Claude CLI & Credentials
# ============================================================================
header "Step 3: Claude CLI & Credentials"
CLAUDE_CREDS_FILE=""
EARLY_CLI_MODE=""
EARLY_CLI_CONTAINER=""
CLI_INSTALLED=false
# --- Phase 1: Search for existing credentials ---
info "Searching for Claude CLI credentials..."
# Strategy 1: Find claude container and check mounts
CLAUDE_CONTAINER=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'claude' | head -1)
if [ -n "$CLAUDE_CONTAINER" ]; then
info "Found Claude container: $CLAUDE_CONTAINER"
MOUNT_SOURCE=$(docker inspect "$CLAUDE_CONTAINER" --format '{{range .Mounts}}{{if or (eq .Destination "/root") (eq .Destination "/root/.claude") (eq .Destination "/home/node/.claude")}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
if [ -n "$MOUNT_SOURCE" ]; then
for suffix in "/.claude/.credentials.json" "/.credentials.json"; do
if [ -f "${MOUNT_SOURCE}${suffix}" ]; then
CLAUDE_CREDS_FILE="${MOUNT_SOURCE}${suffix}"
break
fi
done
fi
fi
# Strategy 2: Search workspace directories
if [ -z "$CLAUDE_CREDS_FILE" ]; then
for path in "$OPENCLAW_CONFIG_DIR"/workspaces/*/config/.claude/.credentials.json; do
if [ -f "$path" ]; then
CLAUDE_CREDS_FILE="$path"
break
fi
done
fi
# Strategy 3: Direct paths
if [ -z "$CLAUDE_CREDS_FILE" ]; then
for path in \
/root/.claude/.credentials.json \
"$HOME/.claude/.credentials.json"; do
if [ -f "$path" ]; then
CLAUDE_CREDS_FILE="$path"
break
fi
done
fi
# --- Phase 2: If no credentials found, detect CLI and help user ---
if [ -z "$CLAUDE_CREDS_FILE" ] || [ ! -f "$CLAUDE_CREDS_FILE" ]; then
warn "No Claude CLI credentials found"
echo ""
# Detect if Claude CLI is installed
if [ -n "$CLAUDE_CONTAINER" ] && docker exec "$CLAUDE_CONTAINER" which claude &>/dev/null; then
CLI_INSTALLED=true
EARLY_CLI_MODE="container"
EARLY_CLI_CONTAINER="$CLAUDE_CONTAINER"
info "Claude Code CLI found in container: $CLAUDE_CONTAINER"
elif command -v claude &>/dev/null; then
CLI_INSTALLED=true
EARLY_CLI_MODE="host"
info "Claude Code CLI found on host: $(which claude)"
fi
# --- CLI not installed: offer to install ---
if ! $CLI_INSTALLED; then
echo -e " ${BOLD}Claude Code CLI is not installed.${NC}"
echo " It's required for automatic OAuth token refresh."
echo ""
echo " Options:"
echo " 1) Install Claude Code CLI now (requires npm/Node.js)"
echo " 2) Show me the install instructions (exit wizard)"
echo " 3) Skip — I'll provide the credentials path manually"
CHOICE=$(ask "Select" "1")
case "$CHOICE" in
1)
if command -v npm &>/dev/null; then
info "Installing Claude Code CLI via npm..."
npm install -g @anthropic-ai/claude-code 2>&1 | tail -5
if command -v claude &>/dev/null; then
success "Claude Code CLI installed successfully"
CLI_INSTALLED=true
EARLY_CLI_MODE="host"
else
error "Installation failed. Check npm/Node.js setup."
fi
else
warn "npm not found. Node.js is required to install Claude Code CLI."
echo ""
echo " Install Node.js first:"
echo " curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -"
echo " apt install -y nodejs"
echo ""
if confirm " Install Node.js + Claude Code CLI now?" "N"; then
info "Installing Node.js LTS..."
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - 2>&1 | tail -3
apt-get install -y nodejs 2>&1 | tail -3
if command -v npm &>/dev/null; then
info "Installing Claude Code CLI..."
npm install -g @anthropic-ai/claude-code 2>&1 | tail -5
if command -v claude &>/dev/null; then
success "Claude Code CLI installed successfully"
CLI_INSTALLED=true
EARLY_CLI_MODE="host"
else
error "Claude CLI installation failed"
fi
else
error "Node.js installation failed"
fi
fi
fi
;;
2)
echo ""
info "Install Claude Code CLI:"
echo ""
echo " # Option A: npm (recommended)"
echo " npm install -g @anthropic-ai/claude-code"
echo ""
echo " # Option B: Run directly with npx"
echo " npx @anthropic-ai/claude-code"
echo ""
echo " # More info:"
echo " https://docs.anthropic.com/en/docs/claude-code"
echo ""
info "After installing and signing in, re-run: sudo ./setup.sh"
exit 0
;;
3)
info "Continuing without Claude CLI..."
;;
esac
fi
# --- CLI installed but no credentials: offer sign-in ---
if $CLI_INSTALLED && ([ -z "$CLAUDE_CREDS_FILE" ] || [ ! -f "$CLAUDE_CREDS_FILE" ]); then
echo ""
warn "Claude Code CLI is installed but not signed in (no OAuth credentials)."
info "You need to authenticate with your Claude Max subscription."
info "This will open an OAuth flow — you'll get a URL to visit in your browser."
echo ""
if confirm " Launch Claude CLI now to sign in?" "Y"; then
echo ""
echo -e " ${BOLD}Complete the OAuth sign-in in your browser.${NC}"
echo -e " ${BOLD}After signing in, press Ctrl+C or type /exit to return here.${NC}"
echo ""
if [ "$EARLY_CLI_MODE" = "container" ]; then
docker exec -it "$EARLY_CLI_CONTAINER" claude || true
else
claude || true
fi
echo ""
info "Checking for credentials after sign-in..."
# Re-search for credentials
for path in \
/root/.claude/.credentials.json \
"$HOME/.claude/.credentials.json"; do
if [ -f "$path" ]; then
CLAUDE_CREDS_FILE="$path"
break
fi
done
# Also check container mounts again
if [ -z "$CLAUDE_CREDS_FILE" ] && [ -n "$CLAUDE_CONTAINER" ]; then
MOUNT_SOURCE=$(docker inspect "$CLAUDE_CONTAINER" --format '{{range .Mounts}}{{if or (eq .Destination "/root") (eq .Destination "/root/.claude") (eq .Destination "/home/node/.claude")}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
if [ -n "$MOUNT_SOURCE" ]; then
for suffix in "/.claude/.credentials.json" "/.credentials.json"; do
if [ -f "${MOUNT_SOURCE}${suffix}" ]; then
CLAUDE_CREDS_FILE="${MOUNT_SOURCE}${suffix}"
break
fi
done
fi
fi
fi
fi
# --- Last resort: ask for manual path ---
if [ -z "$CLAUDE_CREDS_FILE" ] || [ ! -f "$CLAUDE_CREDS_FILE" ]; then
echo ""
warn "Could not find credentials automatically."
CLAUDE_CREDS_FILE=$(ask "Enter path to .credentials.json (or 'quit' to exit)")
if [ "$CLAUDE_CREDS_FILE" = "quit" ] || [ "$CLAUDE_CREDS_FILE" = "q" ]; then
echo ""
info "To set up credentials:"
info " 1. Install Claude Code CLI: npm install -g @anthropic-ai/claude-code"
info " 2. Sign in: claude"
info " 3. Re-run this wizard: sudo ./setup.sh"
exit 1
fi
if [ ! -f "$CLAUDE_CREDS_FILE" ]; then
error "File not found: $CLAUDE_CREDS_FILE"
exit 1
fi
fi
fi
# --- Phase 3: Validate credentials ---
HAS_OAUTH=$(python3 -c "
import json
with open('$CLAUDE_CREDS_FILE') as f:
d = json.load(f)
oauth = d.get('claudeAiOauth', {})
print('yes' if oauth.get('accessToken') else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_OAUTH" != "yes" ]; then
error "Credentials file exists but contains no OAuth tokens."
info "Claude CLI may not be signed in with a Claude Max subscription."
echo ""
if $CLI_INSTALLED; then
info "Sign in to Claude CLI and re-run this wizard:"
info " claude # sign in with OAuth"
info " sudo ./setup.sh # re-run wizard"
else
info "Install Claude Code CLI, sign in, and re-run:"
info " npm install -g @anthropic-ai/claude-code"
info " claude # sign in with OAuth"
info " sudo ./setup.sh # re-run wizard"
fi
exit 1
fi
TOKEN_INFO=$(python3 -c "
import json, time
with open('$CLAUDE_CREDS_FILE') as f:
d = json.load(f)
oauth = d.get('claudeAiOauth', {})
access = oauth.get('accessToken', '')
refresh = oauth.get('refreshToken', '')
expires = oauth.get('expiresAt', 0)
remaining = (expires / 1000 - time.time()) / 3600
status = 'VALID' if remaining > 0 else 'EXPIRED'
print(f'access={access[:20]}... refresh={\"yes\" if refresh else \"no\"} remaining={remaining:.1f}h status={status}')
" 2>/dev/null || echo "error")
if echo "$TOKEN_INFO" | grep -q "error"; then
error "Cannot parse credentials file. Is it a valid Claude CLI .credentials.json?"
exit 1
fi
success "Credentials: $CLAUDE_CREDS_FILE"
info " $TOKEN_INFO"
CURRENT_TOKEN=$(python3 -c "
import json
with open('$CLAUDE_CREDS_FILE') as f:
d = json.load(f)
print(d['claudeAiOauth']['accessToken'])
" 2>/dev/null)
# ============================================================================
# STEP 4: Confirm Configuration
# ============================================================================
header "Step 4: Configuration Summary"
echo -e " ${BOLD}OpenClaw config:${NC} $OPENCLAW_CONFIG_DIR"
echo -e " ${BOLD}Docker compose:${NC} $COMPOSE_DIR"
echo -e " ${BOLD}Environment file:${NC} $ENV_FILE"
echo -e " ${BOLD}Gateway container:${NC} $GATEWAY_CONTAINER"
echo -e " ${BOLD}OAuth file:${NC} $OPENCLAW_OAUTH_FILE"
echo -e " ${BOLD}Claude CLI creds:${NC} $CLAUDE_CREDS_FILE"
echo -e " ${BOLD}Sync method:${NC} $([ "$USE_INOTIFY" = true ] && echo 'inotifywait (real-time)' || echo 'systemd timer (every 6h)')"
echo ""
if ! confirm "Proceed with these settings?" "Y"; then
echo "Aborted."
exit 0
fi
# ============================================================================
# STEP 5: Configure Anthropic Model in OpenClaw
# ============================================================================
header "Step 5: Configuring Anthropic Model"
# Check if anthropic model is already configured
HAS_ANTHROPIC=$(python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
primary = d.get('agents', {}).get('defaults', {}).get('model', {}).get('primary', '')
print('yes' if 'anthropic/' in primary else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_ANTHROPIC" = "yes" ]; then
success "Anthropic model already configured as primary"
else
info "Anthropic model not yet configured"
if confirm " Set anthropic/claude-opus-4-6 as primary model?" "Y"; then
python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
# Set primary model
if 'agents' not in d:
d['agents'] = {}
if 'defaults' not in d['agents']:
d['agents']['defaults'] = {}
if 'model' not in d['agents']['defaults']:
d['agents']['defaults']['model'] = {}
d['agents']['defaults']['model']['primary'] = 'anthropic/claude-opus-4-6'
# Add fallback if not present
fallbacks = d['agents']['defaults']['model'].get('fallbacks', [])
if 'anthropic/claude-sonnet-4-6' not in fallbacks:
fallbacks.insert(0, 'anthropic/claude-sonnet-4-6')
d['agents']['defaults']['model']['fallbacks'] = fallbacks
# Add model aliases
if 'models' not in d['agents']['defaults']:
d['agents']['defaults']['models'] = {}
d['agents']['defaults']['models']['anthropic/claude-opus-4-6'] = {'alias': 'Claude Opus 4.6 (Max)'}
d['agents']['defaults']['models']['anthropic/claude-sonnet-4-6'] = {'alias': 'Claude Sonnet 4.6 (Max)'}
with open('$OPENCLAW_CONFIG_DIR/openclaw.json', 'w') as f:
json.dump(d, f, indent=2)
"
success "Set anthropic/claude-opus-4-6 as primary model"
success "Added anthropic/claude-sonnet-4-6 to fallbacks"
success "Added model aliases"
fi
fi
# Check for broken custom anthropic provider (common mistake)
HAS_CUSTOM_PROVIDER=$(python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
providers = d.get('models', {}).get('providers', {})
print('yes' if 'anthropic' in providers else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_CUSTOM_PROVIDER" = "yes" ]; then
warn "Found custom 'anthropic' in models.providers — this causes 404 errors!"
warn "The built-in provider handles Anthropic. Custom entry causes double /v1 in URLs."
if confirm " Remove the custom anthropic provider entry?" "Y"; then
python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
del d['models']['providers']['anthropic']
with open('$OPENCLAW_CONFIG_DIR/openclaw.json', 'w') as f:
json.dump(d, f, indent=2)
"
success "Removed custom anthropic provider"
fi
fi
# Add ANTHROPIC_OAUTH_TOKEN to .env if missing
if grep -q 'ANTHROPIC_OAUTH_TOKEN=' "$ENV_FILE" 2>/dev/null; then
success "ANTHROPIC_OAUTH_TOKEN already in .env"
else
echo "ANTHROPIC_OAUTH_TOKEN=\"$CURRENT_TOKEN\"" >> "$ENV_FILE"
success "Added ANTHROPIC_OAUTH_TOKEN to .env"
fi
# ============================================================================
# STEP 6: Fix/Create Auth Profiles
# ============================================================================
header "Step 6: Configuring Auth Profiles"
AGENTS_DIR="$OPENCLAW_CONFIG_DIR/agents"
if [ -d "$AGENTS_DIR" ]; then
for agent_dir in "$AGENTS_DIR"/*/agent; do
[ -d "$agent_dir" ] || continue
agent=$(basename "$(dirname "$agent_dir")")
f="$agent_dir/auth-profiles.json"
if [ ! -f "$f" ]; then
# Create new auth-profiles.json
python3 -c "
import json
data = {
'version': 1,
'profiles': {
'anthropic:default': {
'type': 'oauth',
'provider': 'anthropic',
'access': '$CURRENT_TOKEN'
}
},
'lastGood': {
'anthropic': 'anthropic:default'
},
'usageStats': {}
}
with open('$f', 'w') as fh:
json.dump(data, fh, indent=2)
"
success "$agent: created auth-profiles.json"
else
# Fix existing profile
python3 -c "
import json
with open('$f') as fh:
data = json.load(fh)
changed = False
if 'profiles' not in data:
data['profiles'] = {}
p = data['profiles'].get('anthropic:default', {})
if not p:
data['profiles']['anthropic:default'] = {
'type': 'oauth',
'provider': 'anthropic',
'access': '$CURRENT_TOKEN'
}
changed = True
else:
if p.get('type') != 'oauth':
p['type'] = 'oauth'
changed = True
if 'key' in p and 'access' not in p:
p['access'] = p.pop('key')
changed = True
elif 'key' in p:
del p['key']
changed = True
if p.get('access') != '$CURRENT_TOKEN':
p['access'] = '$CURRENT_TOKEN'
changed = True
# Clear cooldown
stats = data.get('usageStats', {}).get('anthropic:default', {})
for k in ['cooldownUntil', 'errorCount', 'failureCounts', 'lastFailureAt']:
if k in stats:
del stats[k]
changed = True
if 'lastGood' not in data:
data['lastGood'] = {}
data['lastGood']['anthropic'] = 'anthropic:default'
if changed:
with open('$f', 'w') as fh:
json.dump(data, fh, indent=2)
print('fixed')
else:
print('ok')
" 2>/dev/null
RESULT=$(python3 -c "
import json
with open('$f') as fh:
data = json.load(fh)
p = data.get('profiles', {}).get('anthropic:default', {})
has_access = 'access' in p
print(f'type={p.get(\"type\",\"none\")} access={\"yes\" if has_access else \"no\"}')
" 2>/dev/null)
success "$agent: $RESULT"
fi
done
else
warn "No agents directory found at $AGENTS_DIR"
fi
# ============================================================================
# STEP 7: Install Sync Script
# ============================================================================
header "Step 7: Installing Sync Service"
INSTALL_DIR="/usr/local/bin"
if [ "$USE_INOTIFY" = true ]; then
# Install inotify-based watcher
SYNC_SCRIPT="$INSTALL_DIR/sync-oauth-token.sh"
sed \
-e "s|@@CLAUDE_CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@OPENCLAW_OAUTH_FILE@@|$OPENCLAW_OAUTH_FILE|g" \
-e "s|@@OPENCLAW_ENV_FILE@@|$ENV_FILE|g" \
-e "s|@@COMPOSE_DIR@@|$COMPOSE_DIR|g" \
"$SCRIPT_DIR/scripts/sync-oauth-token.sh" > "$SYNC_SCRIPT"
chmod +x "$SYNC_SCRIPT"
success "Installed $SYNC_SCRIPT"
# Install systemd service
sed "s|@@SYNC_SCRIPT_PATH@@|$SYNC_SCRIPT|g" \
"$SCRIPT_DIR/templates/sync-oauth-token.service" > /etc/systemd/system/sync-oauth-token.service
success "Installed systemd service"
systemctl daemon-reload
systemctl enable sync-oauth-token.service
systemctl start sync-oauth-token.service
success "Service started and enabled"
else
# Install timer-based refresh
REFRESH_SCRIPT="$INSTALL_DIR/refresh-claude-token.sh"
sed \
-e "s|@@CLAUDE_CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@OPENCLAW_OAUTH_FILE@@|$OPENCLAW_OAUTH_FILE|g" \
-e "s|@@OPENCLAW_ENV_FILE@@|$ENV_FILE|g" \
-e "s|@@COMPOSE_DIR@@|$COMPOSE_DIR|g" \
"$SCRIPT_DIR/scripts/refresh-claude-token.sh" > "$REFRESH_SCRIPT"
chmod +x "$REFRESH_SCRIPT"
success "Installed $REFRESH_SCRIPT"
# Install systemd timer
sed "s|@@REFRESH_SCRIPT_PATH@@|$REFRESH_SCRIPT|g" \
"$SCRIPT_DIR/templates/refresh-claude-token.service" > /etc/systemd/system/refresh-claude-token.service
cp "$SCRIPT_DIR/templates/refresh-claude-token.timer" /etc/systemd/system/
success "Installed systemd timer"
systemctl daemon-reload
systemctl enable refresh-claude-token.timer
systemctl start refresh-claude-token.timer
success "Timer started and enabled (runs every 6 hours)"
fi
# ============================================================================
# STEP 8: Detect Claude CLI & Install Auto-Refresh Trigger
# ============================================================================
header "Step 8: Detecting Claude CLI & Installing Auto-Refresh Trigger"
info "The sync service watches for credential changes, but something must"
info "trigger Claude CLI to actually refresh the token before it expires."
info "This step installs a timer that triggers the refresh automatically."
echo ""
# Detect Claude CLI — container or host?
CLI_MODE=""
CLI_CONTAINER=""
CLI_BASE_URL_OVERRIDE="false"
# Strategy 1: Check for Claude container
CLAUDE_CONTAINER_FOUND=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'claude' | head -1)
if [ -n "$CLAUDE_CONTAINER_FOUND" ]; then
# Verify claude binary exists inside
if docker exec "$CLAUDE_CONTAINER_FOUND" which claude &>/dev/null; then
CLI_MODE="container"
CLI_CONTAINER="$CLAUDE_CONTAINER_FOUND"
info "Found Claude CLI in container: $CLI_CONTAINER"
# Check if ANTHROPIC_BASE_URL needs override
CTR_BASE_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ -n "$CTR_BASE_URL" ] && [ "$CTR_BASE_URL" != "https://api.anthropic.com" ]; then
CLI_BASE_URL_OVERRIDE="true"
info "Container has ANTHROPIC_BASE_URL=$CTR_BASE_URL"
info "Will use temporary override for CLI invocations (does not affect container)"
fi
fi
fi
# Strategy 2: Check host
if [ -z "$CLI_MODE" ]; then
if command -v claude &>/dev/null; then
CLI_MODE="host"
info "Found Claude CLI on host: $(which claude)"
fi
fi
# Strategy 3: Ask user
if [ -z "$CLI_MODE" ]; then
warn "Could not auto-detect Claude CLI"
echo ""
echo " Where is Claude CLI installed?"
echo " 1) In a Docker container"
echo " 2) Directly on this system"
echo " 3) Skip (no auto-refresh trigger)"
CHOICE=$(ask "Select" "3")
case "$CHOICE" in
1)
CLI_MODE="container"
CLI_CONTAINER=$(ask "Enter container name" "claude-proxy")
if ! docker exec "$CLI_CONTAINER" which claude &>/dev/null; then
error "Claude CLI not found in container $CLI_CONTAINER"
CLI_MODE=""
else
# Check base URL
CTR_BASE_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ -n "$CTR_BASE_URL" ] && [ "$CTR_BASE_URL" != "https://api.anthropic.com" ]; then
CLI_BASE_URL_OVERRIDE="true"
fi
fi
;;
2)
CLI_MODE="host"
if ! command -v claude &>/dev/null; then
error "Claude CLI not found on system"
CLI_MODE=""
fi
;;
*)
warn "Skipping auto-refresh trigger installation"
;;
esac
fi
TRIGGER_INSTALLED=false
if [ -n "$CLI_MODE" ]; then
# Test Claude CLI invocation
info "Testing Claude CLI invocation..."
if [ "$CLI_MODE" = "container" ]; then
if [ "$CLI_BASE_URL_OVERRIDE" = "true" ]; then
TEST_CMD="docker exec -e ANTHROPIC_BASE_URL=https://api.anthropic.com $CLI_CONTAINER claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 docker exec -e ANTHROPIC_BASE_URL=https://api.anthropic.com "$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
else
TEST_CMD="docker exec $CLI_CONTAINER claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 docker exec "$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
fi
else
TEST_CMD="claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 claude -p "say ok" --no-session-persistence 2>&1)
fi
TEST_EXIT=$?
if [ "$TEST_EXIT" -eq 0 ] && [ -n "$TEST_OUTPUT" ]; then
success "CLI test passed: $TEST_OUTPUT"
# Verify proxy/container env unchanged (if container mode)
if [ "$CLI_MODE" = "container" ] && [ "$CLI_BASE_URL_OVERRIDE" = "true" ]; then
VERIFY_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ "$VERIFY_URL" = "$CTR_BASE_URL" ]; then
success "Container env unchanged (temporary override confirmed)"
else
warn "Container env may have changed — check manually"
fi
fi
# Install trigger script
TRIGGER_SCRIPT="$INSTALL_DIR/trigger-claude-refresh.sh"
REAUTH_FLAG="$COMPOSE_DIR/REAUTH_NEEDED"
sed \
-e "s|@@CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@REAUTH_FLAG@@|$REAUTH_FLAG|g" \
-e "s|@@CLI_MODE@@|$CLI_MODE|g" \
-e "s|@@CLI_CONTAINER@@|$CLI_CONTAINER|g" \
-e "s|@@CLI_BASE_URL_OVERRIDE@@|$CLI_BASE_URL_OVERRIDE|g" \
"$SCRIPT_DIR/scripts/trigger-claude-refresh.sh" > "$TRIGGER_SCRIPT"
chmod +x "$TRIGGER_SCRIPT"
success "Installed $TRIGGER_SCRIPT"
# Install systemd service and timer
sed "s|@@TRIGGER_SCRIPT_PATH@@|$TRIGGER_SCRIPT|g" \
"$SCRIPT_DIR/templates/trigger-claude-refresh.service" > /etc/systemd/system/trigger-claude-refresh.service
cp "$SCRIPT_DIR/templates/trigger-claude-refresh.timer" /etc/systemd/system/
success "Installed systemd timer"
systemctl daemon-reload
systemctl enable trigger-claude-refresh.timer
systemctl start trigger-claude-refresh.timer
success "Auto-refresh trigger timer started (runs every 30 minutes)"
TRIGGER_INSTALLED=true
# Run the trigger script once to verify
info "Running trigger script for initial verification..."
bash "$TRIGGER_SCRIPT" 2>&1 | while read -r line; do echo " $line"; done
else
error "CLI test failed (exit code: $TEST_EXIT)"
if [ -n "$TEST_OUTPUT" ]; then
error "Output: $TEST_OUTPUT"
fi
warn "Skipping auto-refresh trigger installation"
warn "You can re-run the wizard later to retry"
fi
fi
# ============================================================================
# STEP 9: Initial Sync
# ============================================================================
header "Step 9: Running Initial Sync"
# Create oauth.json
mkdir -p "$(dirname "$OPENCLAW_OAUTH_FILE")"
python3 -c "
import json, time
with open('$CLAUDE_CREDS_FILE') as f:
src = json.load(f)
oauth = src['claudeAiOauth']
openclaw = {
'anthropic': {
'access': oauth['accessToken'],
'refresh': oauth['refreshToken'],
'expires': oauth['expiresAt'],
'scopes': oauth.get('scopes', []),
'subscriptionType': oauth.get('subscriptionType', 'max'),
'rateLimitTier': oauth.get('rateLimitTier', 'default_claude_max_5x')
}
}
with open('$OPENCLAW_OAUTH_FILE', 'w') as f:
json.dump(openclaw, f)
remaining = (oauth['expiresAt'] / 1000 - time.time()) / 3600
print(f'Token: {oauth[\"accessToken\"][:20]}... expires in {remaining:.1f}h')
"
success "Created $OPENCLAW_OAUTH_FILE"
# Update .env token
python3 -c "
import json, re
with open('$CLAUDE_CREDS_FILE') as f:
token = json.load(f)['claudeAiOauth']['accessToken']
with open('$ENV_FILE') as f:
env = f.read()
env = re.sub(r'ANTHROPIC_OAUTH_TOKEN=.*', f'ANTHROPIC_OAUTH_TOKEN=\"{token}\"', env)
with open('$ENV_FILE', 'w') as f:
f.write(env)
"
success "Updated $ENV_FILE"
# Recreate gateway
info "Recreating gateway container..."
cd "$COMPOSE_DIR"
docker compose down openclaw-gateway 2>&1 | tail -2
docker compose up -d openclaw-gateway 2>&1 | tail -2
success "Gateway recreated with fresh token"
# ============================================================================
# STEP 10: Verify
# ============================================================================
header "Step 10: Verification"
sleep 3
ERRORS=0
# Check service
if [ "$USE_INOTIFY" = true ]; then
if systemctl is-active --quiet sync-oauth-token.service; then
success "sync-oauth-token.service is running"
else
error "Service not running"
ERRORS=$((ERRORS + 1))
fi
else
if systemctl is-active --quiet refresh-claude-token.timer; then
success "refresh-claude-token.timer is active"
else
error "Timer not active"
ERRORS=$((ERRORS + 1))
fi
fi
# Check trigger timer
if [ "$TRIGGER_INSTALLED" = true ]; then
if systemctl is-active --quiet trigger-claude-refresh.timer; then
success "trigger-claude-refresh.timer is active"
else
error "Trigger timer not active"
ERRORS=$((ERRORS + 1))
fi
fi
# Check oauth.json
if [ -f "$OPENCLAW_OAUTH_FILE" ]; then
HAS_ACCESS=$(python3 -c "
import json
with open('$OPENCLAW_OAUTH_FILE') as f:
d = json.load(f)
print('yes' if d.get('anthropic', {}).get('access') else 'no')
" 2>/dev/null)
if [ "$HAS_ACCESS" = "yes" ]; then
success "oauth.json has correct format"
else
error "oauth.json missing anthropic.access"
ERRORS=$((ERRORS + 1))
fi
else
error "oauth.json not found"
ERRORS=$((ERRORS + 1))
fi
# Check gateway
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$GATEWAY_CONTAINER"; then
success "Gateway container is running"
CTR_TOKEN=$(docker exec "$GATEWAY_CONTAINER" printenv ANTHROPIC_OAUTH_TOKEN 2>/dev/null | cut -c1-20)
if [ -n "$CTR_TOKEN" ]; then
success "Container has ANTHROPIC_OAUTH_TOKEN: ${CTR_TOKEN}..."
fi
else
error "Gateway container not running"
ERRORS=$((ERRORS + 1))
fi
# ============================================================================
# STEP 11: Gitea Webhook Security (Optional)
# ============================================================================
header "Step 11: Gitea Webhook Security (Optional)"
echo -e " ${DIM}Installs 5-layer HMAC-based security for your Gitea webhook endpoint.${NC}"
echo -e " ${DIM}Provides: IP allowlisting, rate limiting, payload size limits, HMAC${NC}"
echo -e " ${DIM}signature verification, and per-repository allowlisting.${NC}"
echo -e " ${DIM}Source: sol/clawgravity-hook-security (v2.0)${NC}"
echo ""
WEBHOOK_SECURITY_INSTALLED=false
if ! confirm "Set up Gitea webhook security?" "Y"; then
warn "Skipping webhook security setup (can be added later by re-running setup.sh)"
else
# --- 11.1: Prerequisite checks ---
info "Checking webhook security prerequisites..."
WH_MISSING=0
if command -v nginx &>/dev/null; then
success "nginx found: $(nginx -v 2>&1 | head -1)"
else
warn "nginx not found"
echo -e " ${DIM}Install: apt-get install -y nginx${NC}"
WH_MISSING=$((WH_MISSING + 1))
fi
# Check for njs module
NJS_OK=false
if nginx -V 2>&1 | grep -q 'http_js_module\|njs'; then
NJS_OK=true
success "nginx njs module detected"
elif dpkg -l libnginx-mod-http-js 2>/dev/null | grep -q '^ii'; then
NJS_OK=true
success "libnginx-mod-http-js installed"
elif [ -f /usr/lib/nginx/modules/ngx_http_js_module.so ]; then
NJS_OK=true
success "njs module found at /usr/lib/nginx/modules/"
else
warn "nginx njs module not detected"
echo -e " ${DIM}Install: apt-get install -y libnginx-mod-http-js${NC}"
WH_MISSING=$((WH_MISSING + 1))
fi
if command -v jq &>/dev/null; then
success "jq found"
else
warn "jq not found (required for rotation/audit scripts)"
if confirm " Install jq now?" "Y"; then
apt-get install -y jq 2>&1 | tail -3
if command -v jq &>/dev/null; then
success "jq installed"
else
warn "jq installation failed — install manually: apt-get install -y jq"
WH_MISSING=$((WH_MISSING + 1))
fi
else
warn "jq is required for rotation scripts — install later: apt-get install -y jq"
WH_MISSING=$((WH_MISSING + 1))
fi
fi
if command -v openssl &>/dev/null; then
success "openssl found"
else
error "openssl not found — required for secret generation"
WH_MISSING=$((WH_MISSING + 1))
fi
if [ "$WH_MISSING" -gt 0 ]; then
warn "$WH_MISSING prerequisite(s) missing."
if ! confirm " Continue anyway? (some steps may not complete)" "N"; then
warn "Skipping webhook security setup. Install missing deps and re-run."
else
info "Continuing with available tools..."
fi
fi
# --- 11.2: Interactive prompts ---
header "Step 11.2: Webhook Security Configuration"
echo -e " ${DIM}We'll collect the configuration values needed to deploy the security system.${NC}"
echo ""
# Gitea server IP
echo -e " ${BOLD}Gitea Server IP${NC}"
echo -e " ${DIM}The IP address of your Gitea server (for nginx allowlisting, Layer 1).${NC}"
echo -e " ${DIM}Find it with: dig +short YOUR_GITEA_DOMAIN${NC}"
WH_GITEA_IP=$(ask " Gitea server IP" "127.0.0.1")
echo ""
# Webhook HMAC secret
echo -e " ${BOLD}Webhook HMAC Secret${NC}"
echo -e " ${DIM}Used to verify Gitea webhook signatures (Layer 4).${NC}"
echo -e " ${DIM}Must match the secret configured in your Gitea webhook settings.${NC}"
if confirm " Auto-generate a secure secret with openssl?" "Y"; then
WH_SECRET=$(openssl rand -hex 32)
success "Generated secret: ${WH_SECRET:0:8}...${WH_SECRET: -8} (64 hex chars)"
echo -e " ${YELLOW}Important: After setup, update your Gitea webhook secret to this value.${NC}"
echo -e " ${DIM}Gitea Settings -> Webhooks -> [your hook] -> Secret${NC}"
else
WH_SECRET=$(ask " Enter webhook secret" "")
if [ -z "$WH_SECRET" ]; then
WH_SECRET=$(openssl rand -hex 32)
warn "No secret provided — auto-generated: ${WH_SECRET:0:8}..."
fi
fi
echo ""
# Trusted owners
echo -e " ${BOLD}Trusted Gitea Owners${NC}"
echo -e " ${DIM}All repositories from these owners are allowed (Layer 5).${NC}"
echo -e " ${DIM}Space-separated list of Gitea usernames/org names.${NC}"
echo -e " ${DIM}Individual repos can be added later with: gitea-approve-repo owner/repo${NC}"
WH_TRUSTED_OWNERS=$(ask " Trusted owners (space-separated)" "")
echo ""
# ntfy topic (optional)
echo -e " ${BOLD}ntfy Alert Topic (optional)${NC}"
echo -e " ${DIM}Receive instant ntfy.sh alerts when blocked webhooks are detected.${NC}"
echo -e " ${DIM}Leave blank to skip notifications.${NC}"
WH_NTFY_TOPIC=$(ask " ntfy topic URL (e.g. https://ntfy.sh/my-topic)" "")
echo ""
# OpenClaw port
echo -e " ${BOLD}OpenClaw Gateway Port${NC}"
echo -e " ${DIM}The port your OpenClaw gateway listens on (for nginx proxy_pass).${NC}"
WH_OPENCLAW_PORT=$(ask " OpenClaw port" "3000")
echo ""
# Gitea API for rotation script
echo -e " ${BOLD}Gitea Instance URL${NC}"
echo -e " ${DIM}Base URL of your Gitea instance (for secret rotation script).${NC}"
WH_GITEA_INSTANCE=$(ask " Gitea URL" "https://git.example.com")
echo ""
# Webhook URL match pattern
echo -e " ${BOLD}Webhook URL Match Pattern${NC}"
echo -e " ${DIM}Pattern to identify your OpenClaw webhook hooks in Gitea (for rotation).${NC}"
WH_WEBHOOK_URL_MATCH=$(ask " Webhook URL pattern (e.g. yourdomain.com/hooks/gitea)" "")
echo ""
# Scan owners for rotation
echo -e " ${BOLD}Gitea Owner(s) to Scan for Rotation${NC}"
echo -e " ${DIM}Space-separated list of Gitea owners whose repos will have webhooks rotated.${NC}"
WH_SCAN_OWNERS=$(ask " Owners to scan" "${WH_TRUSTED_OWNERS:-your-org}")
echo ""
# Mattermost config for audit (optional)
echo -e " ${BOLD}Mattermost Audit Integration (optional)${NC}"
echo -e " ${DIM}Daily audit summaries posted to a Mattermost channel.${NC}"
WH_MATTERMOST_URL=$(ask " Mattermost URL (leave blank to skip)" "")
WH_MATTERMOST_CHANNEL=""
WH_MATTERMOST_GITEA_REPO=""
if [ -n "$WH_MATTERMOST_URL" ]; then
WH_MATTERMOST_CHANNEL=$(ask " Mattermost channel ID" "")
WH_MATTERMOST_GITEA_REPO=$(ask " Gitea repo for audit anomaly issues (e.g. myorg/webhook-security)" "")
fi
echo ""
# --- 11.3: Deploy files ---
header "Step 11.3: Deploying Webhook Security Files"
WH_INSTALL_DIR="/opt/webhook-security"
WH_NJS_DIR="/etc/nginx/njs"
WH_SECRET_FILE="/etc/nginx/gitea-webhook-secret"
WH_ALLOWLIST_FILE="/etc/nginx/gitea-repo-allowlist.json"
# Create directories
mkdir -p "$WH_INSTALL_DIR/scripts"
mkdir -p "$WH_NJS_DIR"
success "Created $WH_INSTALL_DIR/scripts"
success "Created $WH_NJS_DIR"
# Copy njs HMAC module
cp "$SCRIPT_DIR/scripts/webhook-security/gitea-hmac-verify.js" "$WH_NJS_DIR/"
chmod 644 "$WH_NJS_DIR/gitea-hmac-verify.js"
success "Installed njs module: $WH_NJS_DIR/gitea-hmac-verify.js"
# Write secret file
printf '%s' "$WH_SECRET" > "$WH_SECRET_FILE"
chown root:www-data "$WH_SECRET_FILE" 2>/dev/null || chown root:root "$WH_SECRET_FILE"
chmod 640 "$WH_SECRET_FILE"
success "Installed secret: $WH_SECRET_FILE (permissions: root:www-data 640)"
# Build allowlist JSON from trusted owners
WH_TRUSTED_OWNERS_JSON="[]"
if [ -n "$WH_TRUSTED_OWNERS" ]; then
WH_TRUSTED_OWNERS_JSON=$(python3 -c "
import json, sys
owners = '$WH_TRUSTED_OWNERS'.split()
print(json.dumps(owners))
")
fi
python3 -c "
import json
data = {
'_comment': 'Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).',
'repos': [],
'trusted_owners': $WH_TRUSTED_OWNERS_JSON
}
with open('$WH_ALLOWLIST_FILE', 'w') as f:
json.dump(data, f, indent=4)
f.write('\n')
"
chmod 644 "$WH_ALLOWLIST_FILE"
success "Installed allowlist: $WH_ALLOWLIST_FILE"
info " Trusted owners: ${WH_TRUSTED_OWNERS:-none configured yet}"
info " Add repos later with: gitea-approve-repo owner/repo"
# Install templated scripts with substitutions
for script in rotate-webhook-secret.sh webhook-audit-alert.sh ntfy-blocked-pickup.sh; do
SED_CMD=(sed
-e "s|@@GITEA_API@@|$WH_GITEA_INSTANCE|g"
-e "s|@@WEBHOOK_URL_MATCH@@|$WH_WEBHOOK_URL_MATCH|g"
-e "s|@@SCAN_OWNERS@@|$WH_SCAN_OWNERS|g"
-e "s|@@NTFY_TOPIC@@|${WH_NTFY_TOPIC:-https://ntfy.sh/YOUR_NTFY_TOPIC}|g"
-e "s|@@MATTERMOST_URL@@|${WH_MATTERMOST_URL:-YOUR_MATTERMOST_URL}|g"
-e "s|@@MATTERMOST_CHANNEL_ID@@|${WH_MATTERMOST_CHANNEL:-YOUR_MATTERMOST_CHANNEL_ID}|g"
-e "s|@@GITEA_API_BASE@@|$WH_GITEA_INSTANCE|g"
-e "s|@@GITEA_REPO@@|${WH_MATTERMOST_GITEA_REPO:-YOUR_GITEA_REPO}|g"
)
"${SED_CMD[@]}" "$SCRIPT_DIR/scripts/webhook-security/$script" \
> "$WH_INSTALL_DIR/scripts/$script"
chmod 755 "$WH_INSTALL_DIR/scripts/$script"
success "Installed $WH_INSTALL_DIR/scripts/$script"
done
# Task 11.4: Install gitea-approve-repo to /usr/local/bin/
cp "$SCRIPT_DIR/scripts/webhook-security/gitea-approve-repo" /usr/local/bin/gitea-approve-repo
chmod 755 /usr/local/bin/gitea-approve-repo
success "Installed /usr/local/bin/gitea-approve-repo"
# --- 11.4: nginx config guidance ---
header "Step 11.4: nginx Configuration Guidance"
echo -e " ${BOLD}The security system requires two nginx configuration changes:${NC}"
echo ""
echo -e " ${BOLD}1. In /etc/nginx/nginx.conf (inside the http {} block):${NC}"
echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}"
cat "$SCRIPT_DIR/templates/webhook-security/nginx.conf.example"
echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}"
echo ""
echo -e " ${BOLD}2. In your site config (e.g. /etc/nginx/sites-enabled/openclaw):${NC}"
echo -e " ${DIM}The location blocks for /hooks/gitea are in:${NC}"
echo -e " ${DIM}$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example${NC}"
echo ""
echo -e " ${DIM}Replace YOUR_DOMAIN, YOUR_GITEA_SERVER_IP, YOUR_OPENCLAW_PORT,${NC}"
echo -e " ${DIM}and YOUR_OPENCLAW_GATEWAY_TOKEN with your actual values.${NC}"
echo ""
if confirm " Display full site config example now?" "N"; then
echo ""
echo -e "${DIM}"
cat "$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example" | \
sed -e "s|YOUR_GITEA_SERVER_IP|$WH_GITEA_IP|g" \
-e "s|YOUR_OPENCLAW_PORT|$WH_OPENCLAW_PORT|g"
echo -e "${NC}"
fi
# --- 11.5: Optional cron setup ---
header "Step 11.5: Cron Jobs (Optional)"
echo -e " ${DIM}Three automated tasks can be set up via cron:${NC}"
echo ""
echo " 1. Secret rotation (monthly, 1st of month at 3am UTC)"
echo " Command: $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh"
echo ""
echo " 2. Blocked webhook alerts (every minute, scans nginx error.log)"
echo " Command: $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh"
echo ""
echo " 3. Daily audit summary (5am UTC)"
echo " Command: $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh"
echo ""
CRON_ADDED=0
if confirm " Install monthly secret rotation cron job?" "Y"; then
CRON_LINE="0 3 1 * * $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh >> /var/log/webhook-secret-rotation.log 2>&1"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added rotation cron: 0 3 1 * * ..."
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ -n "$WH_NTFY_TOPIC" ] && confirm " Install blocked-webhook ntfy alerts cron (every minute)?" "Y"; then
CRON_LINE="* * * * * $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added ntfy-blocked-pickup cron: * * * * * ..."
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ -n "$WH_MATTERMOST_URL" ] && confirm " Install daily audit summary cron (5am UTC)?" "Y"; then
CRON_LINE="5 0 * * * MATTERMOST_BOT_TOKEN=YOUR_BOT_TOKEN $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh >> /var/log/webhook-audit.log 2>&1"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added audit cron: 5 0 * * * ..."
warn "Edit the cron entry to set MATTERMOST_BOT_TOKEN: crontab -e"
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ "$CRON_ADDED" -eq 0 ]; then
info "No cron jobs added. You can add them manually — see docs/WEBHOOK-SECURITY.md"
fi
# --- 11.6: Verification ---
header "Step 11.6: Webhook Security Verification"
WH_ERRORS=0
# Check njs module file
if [ -f "$WH_NJS_DIR/gitea-hmac-verify.js" ]; then
success "njs module installed: $WH_NJS_DIR/gitea-hmac-verify.js"
else
error "njs module NOT found: $WH_NJS_DIR/gitea-hmac-verify.js"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check secret file
if [ -f "$WH_SECRET_FILE" ]; then
SECRET_LEN=$(wc -c < "$WH_SECRET_FILE")
SECRET_PERMS=$(stat -c '%a' "$WH_SECRET_FILE" 2>/dev/null || stat -f '%Mp%Lp' "$WH_SECRET_FILE" 2>/dev/null)
if [ "$SECRET_LEN" -ge 32 ]; then
success "Secret file: $WH_SECRET_FILE (${SECRET_LEN} chars, perms: $SECRET_PERMS)"
else
error "Secret file too short (${SECRET_LEN} chars)"
WH_ERRORS=$((WH_ERRORS + 1))
fi
else
error "Secret file NOT found: $WH_SECRET_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check allowlist
if [ -f "$WH_ALLOWLIST_FILE" ]; then
if python3 -c "import json; json.load(open('$WH_ALLOWLIST_FILE'))" 2>/dev/null; then
success "Allowlist file: $WH_ALLOWLIST_FILE (valid JSON)"
else
error "Allowlist file has invalid JSON: $WH_ALLOWLIST_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
else
error "Allowlist file NOT found: $WH_ALLOWLIST_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check gitea-approve-repo
if [ -x /usr/local/bin/gitea-approve-repo ]; then
success "gitea-approve-repo: /usr/local/bin/gitea-approve-repo (executable)"
else
error "gitea-approve-repo not found/executable"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# nginx config test (if nginx is available and njs module found)
if command -v nginx &>/dev/null && $NJS_OK; then
if nginx -t 2>/dev/null; then
success "nginx -t: config is valid"
else
warn "nginx -t failed — config changes may be needed (see Step 11.4 guidance above)"
fi
fi
if [ "$WH_ERRORS" -eq 0 ]; then
success "Webhook security installed successfully"
WEBHOOK_SECURITY_INSTALLED=true
else
warn "$WH_ERRORS webhook security error(s) — review output above"
fi
echo ""
info "Reference: $SCRIPT_DIR/docs/WEBHOOK-SECURITY.md for full documentation"
info "Security audit: $SCRIPT_DIR/docs/SECURITY-AUDIT.md"
fi
# ============================================================================
# SUMMARY
# ============================================================================
echo ""
echo -e "${BOLD}${CYAN}"
echo " ╔══════════════════════════════════════════════════════╗"
if [ "$ERRORS" -eq 0 ]; then
echo " ║ Setup Complete! ║"
else
echo " ║ Setup Complete (with $ERRORS warning(s)) ║"
fi
echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e " ${BOLD}Installed:${NC}"
if [ "$USE_INOTIFY" = true ]; then
echo " - sync-oauth-token.sh (real-time file watcher)"
echo " - sync-oauth-token.service (systemd)"
else
echo " - refresh-claude-token.sh (direct API refresh)"
echo " - refresh-claude-token.timer (every 6 hours)"
fi
if [ "$TRIGGER_INSTALLED" = true ]; then
echo " - trigger-claude-refresh.sh (auto-refresh trigger)"
echo " - trigger-claude-refresh.timer (every 30 minutes)"
echo " - Claude CLI mode: $CLI_MODE$([ "$CLI_MODE" = "container" ] && echo " ($CLI_CONTAINER)")"
fi
echo " - Anthropic model configured in openclaw.json"
echo " - Auth profiles updated for all agents"
echo " - oauth.json created with fresh token"
if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then
echo " - Webhook security: njs module, secret, allowlist, helper scripts"
fi
echo ""
echo -e " ${BOLD}Useful commands:${NC}"
if [ "$USE_INOTIFY" = true ]; then
echo " journalctl -u sync-oauth-token.service -f # Watch sync logs"
echo " systemctl restart sync-oauth-token.service # Force re-sync"
else
echo " journalctl -u refresh-claude-token.service # View last refresh"
echo " systemctl list-timers refresh-claude-token* # Check timer"
fi
if [ "$TRIGGER_INSTALLED" = true ]; then
echo " journalctl -u trigger-claude-refresh -n 20 # Trigger logs"
echo " systemctl list-timers trigger-claude-refresh* # Check trigger timer"
fi
if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then
echo " gitea-approve-repo owner/repo # Add repo to allowlist"
echo " cat /etc/nginx/gitea-repo-allowlist.json # View allowlist"
echo " /opt/webhook-security/scripts/rotate-webhook-secret.sh --dry-run"
fi
echo " ./scripts/verify.sh # Health check"
echo " ./setup.sh --uninstall # Remove everything"
echo ""
echo -e " ${DIM}Created by ROOH — <project-url>${NC}"