#!/usr/bin/env python3 """ Tunnel Rotation Scheduler Automatically rotates Cloudflare Tunnel credentials based on age policy. Usage: python3 tunnel-rotation-scheduler.py --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())