Files
vm-cloudflare/scripts/tunnel-rotation-scheduler.py
Vault Sovereign 37a867c485 Initial commit: Cloudflare infrastructure with WAF Intelligence
- Complete Cloudflare Terraform configuration (DNS, WAF, tunnels, access)
- WAF Intelligence MCP server with threat analysis and ML classification
- GitOps automation with PR workflows and drift detection
- Observatory monitoring stack with Prometheus/Grafana
- IDE operator rules for governed development
- Security playbooks and compliance frameworks
- Autonomous remediation and state reconciliation
2025-12-16 18:31:53 +00:00

378 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Tunnel Rotation Scheduler
Automatically rotates Cloudflare Tunnel credentials based on age policy.
Usage:
python3 tunnel-rotation-scheduler.py --account-id <ACCOUNT_ID>
Environment Variables:
CLOUDFLARE_API_TOKEN - API token with Tunnel permissions
CLOUDFLARE_ACCOUNT_ID - Account ID (or use --account-id)
TUNNEL_MAX_AGE_DAYS - Maximum tunnel credential age (default: 90)
Output:
- Creates new tunnel with fresh credentials
- Updates DNS routes
- Destroys old tunnel
- Emits rotation receipts
"""
import argparse
import base64
import hashlib
import json
import os
import secrets
import subprocess
import sys
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
import requests
# Configuration
CF_API_BASE = "https://api.cloudflare.com/client/v4"
RECEIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "receipts")
DEFAULT_MAX_AGE_DAYS = 90
class TunnelRotator:
"""Handles Cloudflare Tunnel credential rotation."""
def __init__(self, api_token: str, account_id: str, max_age_days: int = DEFAULT_MAX_AGE_DAYS):
self.api_token = api_token
self.account_id = account_id
self.max_age_days = max_age_days
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json"
})
self.rotations: List[Dict[str, Any]] = []
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make API request with error handling."""
url = f"{CF_API_BASE}{endpoint}"
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
data = response.json()
if not data.get("success", False):
errors = data.get("errors", [])
raise Exception(f"Cloudflare API error: {errors}")
return data
def get_tunnels(self) -> List[Dict[str, Any]]:
"""Fetch all tunnels for the account."""
data = self._request("GET", f"/accounts/{self.account_id}/cfd_tunnel")
return data.get("result", [])
def get_tunnel_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""Find tunnel by name."""
tunnels = self.get_tunnels()
for t in tunnels:
if t.get("name") == name and not t.get("deleted_at"):
return t
return None
def check_tunnel_age(self, tunnel: Dict[str, Any]) -> Tuple[int, bool]:
"""Check tunnel age and whether rotation is needed."""
created_at = tunnel.get("created_at")
if not created_at:
return 0, False
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
age = datetime.now(timezone.utc) - created
age_days = age.days
needs_rotation = age_days >= self.max_age_days
return age_days, needs_rotation
def generate_tunnel_secret(self) -> str:
"""Generate cryptographically secure tunnel secret."""
return base64.b64encode(secrets.token_bytes(32)).decode()
def create_tunnel(self, name: str, secret: str) -> Dict[str, Any]:
"""Create a new tunnel."""
data = self._request(
"POST",
f"/accounts/{self.account_id}/cfd_tunnel",
json={
"name": name,
"tunnel_secret": secret,
}
)
return data.get("result", {})
def delete_tunnel(self, tunnel_id: str) -> bool:
"""Delete a tunnel."""
try:
self._request("DELETE", f"/accounts/{self.account_id}/cfd_tunnel/{tunnel_id}")
return True
except Exception as e:
print(f"Warning: Failed to delete tunnel {tunnel_id}: {e}")
return False
def get_tunnel_routes(self, tunnel_id: str) -> List[Dict[str, Any]]:
"""Get DNS routes for a tunnel."""
try:
data = self._request(
"GET",
f"/accounts/{self.account_id}/cfd_tunnel/{tunnel_id}/configurations"
)
config = data.get("result", {}).get("config", {})
return config.get("ingress", [])
except Exception:
return []
def update_dns_route(self, zone_id: str, hostname: str, tunnel_id: str) -> bool:
"""Update DNS CNAME to point to new tunnel."""
tunnel_cname = f"{tunnel_id}.cfargotunnel.com"
# Find existing record
records_data = self._request(
"GET",
f"/zones/{zone_id}/dns_records",
params={"name": hostname, "type": "CNAME"}
)
records = records_data.get("result", [])
if records:
# Update existing
record_id = records[0]["id"]
self._request(
"PATCH",
f"/zones/{zone_id}/dns_records/{record_id}",
json={"content": tunnel_cname}
)
else:
# Create new
self._request(
"POST",
f"/zones/{zone_id}/dns_records",
json={
"type": "CNAME",
"name": hostname,
"content": tunnel_cname,
"proxied": True
}
)
return True
def rotate_tunnel(
self,
old_tunnel: Dict[str, Any],
zone_id: Optional[str] = None,
hostnames: Optional[List[str]] = None,
dry_run: bool = False
) -> Dict[str, Any]:
"""Rotate a tunnel to fresh credentials."""
old_id = old_tunnel["id"]
old_name = old_tunnel["name"]
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
new_name = f"{old_name.split('-')[0]}-{timestamp}"
print(f"Rotating tunnel: {old_name} -> {new_name}")
rotation_record = {
"old_tunnel_id": old_id,
"old_tunnel_name": old_name,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pending",
}
if dry_run:
print(" [DRY RUN] Would create new tunnel and update routes")
rotation_record["status"] = "dry_run"
rotation_record["actions"] = ["create_tunnel", "update_routes", "delete_old"]
self.rotations.append(rotation_record)
return rotation_record
try:
# Generate new secret
new_secret = self.generate_tunnel_secret()
# Create new tunnel
new_tunnel = self.create_tunnel(new_name, new_secret)
new_id = new_tunnel["id"]
rotation_record["new_tunnel_id"] = new_id
rotation_record["new_tunnel_name"] = new_name
print(f" Created new tunnel: {new_id}")
# Update DNS routes if zone and hostnames provided
if zone_id and hostnames:
for hostname in hostnames:
try:
self.update_dns_route(zone_id, hostname, new_id)
print(f" Updated DNS route: {hostname}")
except Exception as e:
print(f" Warning: Failed to update route {hostname}: {e}")
# Wait for propagation (in production, would verify connectivity)
print(" Waiting for propagation...")
# Delete old tunnel
if self.delete_tunnel(old_id):
print(f" Deleted old tunnel: {old_id}")
rotation_record["old_tunnel_deleted"] = True
else:
rotation_record["old_tunnel_deleted"] = False
rotation_record["status"] = "success"
rotation_record["new_secret_hash"] = hashlib.sha256(new_secret.encode()).hexdigest()[:16]
except Exception as e:
rotation_record["status"] = "failed"
rotation_record["error"] = str(e)
print(f" Error: {e}")
self.rotations.append(rotation_record)
return rotation_record
def scan_and_rotate(
self,
zone_id: Optional[str] = None,
hostname_map: Optional[Dict[str, List[str]]] = None,
dry_run: bool = False
) -> List[Dict[str, Any]]:
"""Scan all tunnels and rotate those exceeding max age."""
print(f"Scanning tunnels (max age: {self.max_age_days} days)...")
tunnels = self.get_tunnels()
for tunnel in tunnels:
if tunnel.get("deleted_at"):
continue
name = tunnel.get("name", "unknown")
age_days, needs_rotation = self.check_tunnel_age(tunnel)
status = "NEEDS ROTATION" if needs_rotation else "OK"
print(f" {name}: {age_days} days old [{status}]")
if needs_rotation:
hostnames = hostname_map.get(name, []) if hostname_map else None
self.rotate_tunnel(tunnel, zone_id, hostnames, dry_run)
return self.rotations
def create_rotation_receipt(rotations: List[Dict[str, Any]], account_id: str) -> Dict[str, Any]:
"""Create VaultMesh receipt for rotation cycle."""
successful = [r for r in rotations if r.get("status") == "success"]
failed = [r for r in rotations if r.get("status") == "failed"]
return {
"receipt_type": "tunnel_rotation_cycle",
"schema_version": "vm_tunnel_rotation_v1",
"timestamp": datetime.now(timezone.utc).isoformat(),
"account_id": account_id,
"summary": {
"total_rotated": len(successful),
"failed": len(failed),
"skipped": len(rotations) - len(successful) - len(failed),
},
"rotations": rotations,
"cycle_hash": hashlib.sha256(
json.dumps(rotations, sort_keys=True).encode()
).hexdigest(),
}
def main():
parser = argparse.ArgumentParser(description="Tunnel Rotation Scheduler")
parser.add_argument("--account-id", default=os.environ.get("CLOUDFLARE_ACCOUNT_ID"),
help="Cloudflare Account ID")
parser.add_argument("--zone-id", default=os.environ.get("CLOUDFLARE_ZONE_ID"),
help="Zone ID for DNS route updates")
parser.add_argument("--max-age", type=int,
default=int(os.environ.get("TUNNEL_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS)),
help=f"Maximum tunnel age in days (default: {DEFAULT_MAX_AGE_DAYS})")
parser.add_argument("--tunnel-name", help="Rotate specific tunnel by name")
parser.add_argument("--dry-run", action="store_true", help="Simulate rotation without changes")
parser.add_argument("--force", action="store_true", help="Force rotation regardless of age")
parser.add_argument("--output-dir", default=RECEIPT_DIR, help="Output directory for receipts")
args = parser.parse_args()
# Validate inputs
api_token = os.environ.get("CLOUDFLARE_API_TOKEN")
if not api_token:
print("Error: CLOUDFLARE_API_TOKEN environment variable required", file=sys.stderr)
sys.exit(1)
if not args.account_id:
print("Error: Account ID required (--account-id or CLOUDFLARE_ACCOUNT_ID)", file=sys.stderr)
sys.exit(1)
# Ensure output directory exists
os.makedirs(args.output_dir, exist_ok=True)
# Initialize rotator
rotator = TunnelRotator(api_token, args.account_id, args.max_age)
print("=" * 50)
print("Tunnel Rotation Scheduler")
print("=" * 50)
print(f"Account ID: {args.account_id}")
print(f"Max Age: {args.max_age} days")
print(f"Dry Run: {args.dry_run}")
print(f"Force: {args.force}")
print("")
if args.tunnel_name:
# Rotate specific tunnel
tunnel = rotator.get_tunnel_by_name(args.tunnel_name)
if not tunnel:
print(f"Error: Tunnel '{args.tunnel_name}' not found", file=sys.stderr)
sys.exit(1)
if args.force:
rotator.rotate_tunnel(tunnel, args.zone_id, dry_run=args.dry_run)
else:
age_days, needs_rotation = rotator.check_tunnel_age(tunnel)
if needs_rotation:
rotator.rotate_tunnel(tunnel, args.zone_id, dry_run=args.dry_run)
else:
print(f"Tunnel '{args.tunnel_name}' is {age_days} days old, no rotation needed")
else:
# Scan and rotate all
rotator.scan_and_rotate(args.zone_id, dry_run=args.dry_run)
# Generate receipt
if rotator.rotations:
receipt = create_rotation_receipt(rotator.rotations, args.account_id)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
receipt_filename = f"tunnel-rotation-{timestamp}.json"
receipt_path = os.path.join(args.output_dir, receipt_filename)
with open(receipt_path, "w") as f:
json.dump(receipt, f, indent=2, sort_keys=True)
print("")
print(f"Receipt written to: {receipt_path}")
# Summary
print("")
print("=" * 50)
print("Rotation Summary")
print("=" * 50)
successful = [r for r in rotator.rotations if r.get("status") == "success"]
failed = [r for r in rotator.rotations if r.get("status") == "failed"]
dry_runs = [r for r in rotator.rotations if r.get("status") == "dry_run"]
print(f"Successful: {len(successful)}")
print(f"Failed: {len(failed)}")
print(f"Dry Run: {len(dry_runs)}")
if failed:
print("")
print("Failed rotations:")
for r in failed:
print(f" - {r.get('old_tunnel_name')}: {r.get('error')}")
# Exit code
return 0 if len(failed) == 0 else 1
if __name__ == "__main__":
sys.exit(main())