367 lines
13 KiB
Python
Executable File
367 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
wisp-backup.py — Pull configs and logs from CCR towers and upload to S3.
|
|
|
|
Usage:
|
|
./wisp-backup.py # Uses config.yaml in same directory
|
|
./wisp-backup.py --config /path/to/config.yaml
|
|
./wisp-backup.py --vpn-only # Just connect VPN, don't backup
|
|
./wisp-backup.py --dry-run # Show what would be done, don't upload
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import gzip
|
|
import io
|
|
import os
|
|
import paramiko
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import yaml
|
|
from pathlib import Path
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
|
def load_config(path: str) -> dict:
|
|
with open(path, 'r') as f:
|
|
cfg = yaml.safe_load(f)
|
|
return cfg
|
|
|
|
|
|
def run_vpn_script(action: str, script_dir: str) -> bool:
|
|
"""Call home-router-vpn.sh up/down/status."""
|
|
vpn_script = os.path.join(script_dir, "home-router-vpn.sh")
|
|
if not os.path.exists(vpn_script):
|
|
print(f"[!] VPN script not found: {vpn_script}")
|
|
return False
|
|
|
|
result = subprocess.run(
|
|
["bash", vpn_script, action],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
for line in result.stdout.splitlines():
|
|
print(f" {line}")
|
|
if result.stderr.strip():
|
|
for line in result.stderr.splitlines():
|
|
print(f" [stderr] {line}")
|
|
|
|
return result.returncode == 0
|
|
|
|
|
|
def ssh_connect(host: str, port: int, username: str, key_path: str = None,
|
|
password: str = None, timeout: int = 30):
|
|
"""Open an SSH connection to a CCR router."""
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
if key_path and os.path.exists(key_path):
|
|
# Try Ed25519 first, fall back to RSA
|
|
try:
|
|
key = paramiko.Ed25519Key.from_private_key_file(key_path)
|
|
except paramiko.ssh_exception.SSHException:
|
|
key = paramiko.RSAKey.from_private_key_file(key_path)
|
|
client.connect(host, port=port, username=username, pkey=key,
|
|
timeout=timeout, allow_agent=False, look_for_keys=False)
|
|
elif password:
|
|
client.connect(host, port=port, username=username, password=password,
|
|
timeout=timeout, allow_agent=False, look_for_keys=False)
|
|
else:
|
|
# Try default key locations
|
|
client.connect(host, port=port, username=username,
|
|
timeout=timeout, allow_agent=True)
|
|
return client
|
|
|
|
|
|
def run_ssh_command(client, command: str, timeout: int = 30) -> str:
|
|
"""Run a command over SSH and return stdout."""
|
|
_, stdout, stderr = client.exec_command(command, timeout=timeout)
|
|
exit_status = stdout.channel.recv_exit_status()
|
|
output = stdout.read().decode('utf-8', errors='replace')
|
|
|
|
if exit_status != 0:
|
|
err = stderr.read().decode('utf-8', errors='replace')
|
|
print(f" [warn] Command exited {exit_status}: {err.strip()}")
|
|
return output
|
|
|
|
|
|
def fetch_ccr_config(client) -> str:
|
|
"""Fetch full CCR config via /export terse."""
|
|
print(" Fetching config ...")
|
|
config = run_ssh_command(client, "/export terse")
|
|
return config
|
|
|
|
|
|
def fetch_ccr_logs(client, lines: int = 500) -> str:
|
|
"""Fetch recent CCR log entries."""
|
|
print(f" Fetching last {lines} log lines ...")
|
|
logs = run_ssh_command(client, f"/log print without-paging count-only={lines}")
|
|
return logs
|
|
|
|
|
|
def compress_text(text: str, filename: str) -> str:
|
|
"""Write text to a .gz file and return the path."""
|
|
gz_path = filename + ".gz"
|
|
with gzip.open(gz_path, 'wt', encoding='utf-8') as f:
|
|
f.write(text)
|
|
return gz_path
|
|
|
|
|
|
def upload_to_s3(local_path: str, s3_path: str, bucket: str, region: str,
|
|
endpoint: str = None):
|
|
"""Upload file to S3 (or Wasabi/S3-compatible) using awscli."""
|
|
dest = f"s3://{bucket}/{s3_path}"
|
|
cmd = ["aws", "s3", "cp", local_path, dest, "--region", region]
|
|
if endpoint:
|
|
cmd += ["--endpoint-url", endpoint]
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=60
|
|
)
|
|
if result.returncode == 0:
|
|
print(f" [s3] Uploaded → {dest}")
|
|
else:
|
|
print(f" [s3] Upload failed: {result.stderr.strip()}")
|
|
return result.returncode == 0
|
|
|
|
|
|
def backup_tower(tower: dict, cfg: dict, date_str: str, temp_dir: str) -> dict:
|
|
"""Backup a single tower. Returns dict of results."""
|
|
name = tower['name']
|
|
ip = tower['ip']
|
|
site = tower['site']
|
|
port = tower.get('ssh_port', 22)
|
|
user = tower.get('ssh_user', 'admin')
|
|
key_path = tower.get('ssh_key')
|
|
password = tower.get('ssh_password')
|
|
log_lines = cfg.get('backup', {}).get('log_lines', 500)
|
|
compress = cfg.get('backup', {}).get('compress', True)
|
|
timeout = cfg.get('backup', {}).get('timeout_seconds', 60)
|
|
|
|
s3_cfg = cfg.get('s3', {})
|
|
bucket = s3_cfg['bucket']
|
|
region = s3_cfg.get('region', 'us-east-1')
|
|
prefix = s3_cfg.get('prefix', 'wisp-backups')
|
|
endpoint = s3_cfg.get('endpoint') # Wasabi or S3-compatible endpoint
|
|
|
|
results = {
|
|
'tower': name,
|
|
'site': site,
|
|
'ip': ip,
|
|
'config': False,
|
|
'logs': False,
|
|
'error': None
|
|
}
|
|
|
|
# Create local dirs
|
|
tower_temp = Path(temp_dir) / site / name / date_str
|
|
tower_temp.mkdir(parents=True, exist_ok=True)
|
|
|
|
s3_config_base = f"{prefix}/configs/{site}/{date_str}"
|
|
s3_logs_base = f"{prefix}/logs/{site}/{date_str}"
|
|
|
|
print(f"\n [{name}] ({ip}) — backing up ...")
|
|
|
|
try:
|
|
client = ssh_connect(ip, port, user, key_path, password, timeout=timeout)
|
|
except Exception as e:
|
|
results['error'] = f"SSH connection failed: {e}"
|
|
print(f" [FAIL] {results['error']}")
|
|
return results
|
|
|
|
try:
|
|
# --- Config ---
|
|
config_text = fetch_ccr_config(client)
|
|
config_filename = f"{tower_temp}/{name}-{date_str}.rsc"
|
|
with open(config_filename, 'w') as f:
|
|
f.write(config_text)
|
|
|
|
if compress:
|
|
config_filename = compress_text(config_text,
|
|
str(tower_temp / f"{name}-{date_str}.rsc"))
|
|
remote_config = f"{s3_config_base}/{name}-{date_str}.rsc.gz"
|
|
else:
|
|
remote_config = f"{s3_config_base}/{name}-{date_str}.rsc"
|
|
|
|
upload_to_s3(config_filename, remote_config, bucket, region, endpoint)
|
|
results['config'] = True
|
|
|
|
# --- Logs ---
|
|
logs_text = fetch_ccr_logs(client, log_lines)
|
|
log_filename = f"{tower_temp}/{name}-{date_str}.log"
|
|
with open(log_filename, 'w') as f:
|
|
f.write(logs_text)
|
|
|
|
if compress:
|
|
log_filename = compress_text(logs_text,
|
|
str(tower_temp / f"{name}-{date_str}.log"))
|
|
remote_log = f"{s3_logs_base}/{name}-{date_str}.log.gz"
|
|
else:
|
|
remote_log = f"{s3_logs_base}/{name}-{date_str}.log"
|
|
|
|
upload_to_s3(log_filename, remote_log, bucket, region, endpoint)
|
|
results['logs'] = True
|
|
|
|
except Exception as e:
|
|
results['error'] = str(e)
|
|
print(f" [FAIL] {e}")
|
|
finally:
|
|
client.close()
|
|
|
|
return results
|
|
|
|
|
|
def cleanup_old_local(temp_dir: str, retention_days: int):
|
|
"""Remove local backup copies older than retention_days."""
|
|
cutoff = time.time() - (retention_days * 86400)
|
|
removed = 0
|
|
for root, dirs, files in os.walk(temp_dir):
|
|
for f in files:
|
|
path = os.path.join(root, f)
|
|
if os.path.getmtime(path) < cutoff:
|
|
os.remove(path)
|
|
removed += 1
|
|
# Clean empty dirs
|
|
for d in dirs:
|
|
dpath = os.path.join(root, d)
|
|
try:
|
|
os.rmdir(dpath)
|
|
except OSError:
|
|
pass
|
|
if removed:
|
|
print(f"[*] Cleaned up {removed} old local files")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="WISP CCR Backup to S3")
|
|
parser.add_argument('--config', default=None,
|
|
help='Path to config.yaml (default: ./config.yaml)')
|
|
parser.add_argument('--vpn-only', action='store_true',
|
|
help='Just connect VPN, do not backup')
|
|
parser.add_argument('--dry-run', action='store_true',
|
|
help='Show what would be done without executing')
|
|
parser.add_argument('--tower', default=None,
|
|
help='Backup only a specific tower name')
|
|
parser.add_argument('--parallel', type=int, default=5,
|
|
help='Max parallel tower backups (default: 5)')
|
|
args = parser.parse_args()
|
|
|
|
# Resolve config path
|
|
if args.config:
|
|
config_path = args.config
|
|
else:
|
|
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"config.yaml")
|
|
|
|
if not os.path.exists(config_path):
|
|
print(f"[!] Config not found: {config_path}")
|
|
print(" Create it from the template or pass --config")
|
|
sys.exit(1)
|
|
|
|
cfg = load_config(config_path)
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
temp_dir = cfg.get('backup', {}).get('temp_dir', '/tmp/wisp-backups')
|
|
retention_days = cfg.get('backup', {}).get('retention_days', 7)
|
|
|
|
date_str = datetime.datetime.now().strftime('%Y-%m-%d')
|
|
|
|
print(f"╔══════════════════════════════════════════╗")
|
|
print(f"║ WISP Backup — {date_str}")
|
|
print(f"╚══════════════════════════════════════════╝")
|
|
|
|
# Step 1: Connect VPN (only if router not already reachable via WireGuard)
|
|
vpn_was_connected = False
|
|
print("\n[*] Step 1: Checking connectivity via WireGuard ...")
|
|
router_ip = cfg.get('towers', [{}])[0].get('ip', '10.77.0.2') if cfg.get('towers') else '10.77.0.2'
|
|
ping_check = subprocess.run(
|
|
["ping", "-c", "1", "-W", "2", router_ip],
|
|
capture_output=True, timeout=5
|
|
)
|
|
if ping_check.returncode == 0:
|
|
print(f" [+] Router reachable at {router_ip} via WireGuard — skipping VPN")
|
|
else:
|
|
print(f" [-] Router not reachable via WireGuard, trying L2TP/IPsec VPN ...")
|
|
if not run_vpn_script("up", script_dir):
|
|
print("[-] VPN connection failed. Aborting.")
|
|
sys.exit(1)
|
|
vpn_was_connected = True
|
|
|
|
# Safety: confirm internet still works before proceeding
|
|
print("[*] Verifying internet connectivity ...")
|
|
check = subprocess.run(
|
|
["ping", "-c", "1", "-W", "3", "8.8.8.8"],
|
|
capture_output=True, timeout=10
|
|
)
|
|
if check.returncode != 0:
|
|
print("[-] Internet connectivity issue after VPN/backup prep")
|
|
if vpn_was_connected:
|
|
run_vpn_script("down", script_dir)
|
|
sys.exit(1)
|
|
print("[+] Internet OK — proceeding with backup")
|
|
|
|
if args.vpn_only:
|
|
print("\n[*] VPN connected (--vpn-only mode). Exiting.")
|
|
print(" Run 'home-router-vpn.sh down' to disconnect.")
|
|
return
|
|
|
|
# Step 2: Backup towers
|
|
print("\n[*] Step 2: Backing up towers ...")
|
|
towers = cfg.get('towers', [])
|
|
if args.tower:
|
|
towers = [t for t in towers if t['name'] == args.tower]
|
|
if not towers:
|
|
print(f"[-] Tower '{args.tower}' not found in config")
|
|
if vpn_was_connected:
|
|
run_vpn_script("down", script_dir)
|
|
sys.exit(1)
|
|
|
|
if args.dry_run:
|
|
print("\n [DRY RUN] Would backup these towers:")
|
|
for t in towers:
|
|
print(f" - {t['name']} ({t['ip']}) @ site: {t['site']}")
|
|
print(f"\n [DRY RUN] Would upload to s3://{cfg['s3']['bucket']}/")
|
|
if vpn_was_connected:
|
|
run_vpn_script("down", script_dir)
|
|
return
|
|
|
|
results = []
|
|
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
|
|
futures = {
|
|
executor.submit(backup_tower, t, cfg, date_str, temp_dir): t
|
|
for t in towers
|
|
}
|
|
for future in as_completed(futures):
|
|
results.append(future.result())
|
|
|
|
# Step 3: Disconnect VPN if we connected it
|
|
print("\n[*] Step 3: Disconnecting VPN ...")
|
|
if vpn_was_connected:
|
|
run_vpn_script("down", script_dir)
|
|
else:
|
|
print(" (WireGuard already active — no VPN teardown needed)")
|
|
|
|
# Step 4: Cleanup
|
|
print("\n[*] Step 4: Cleaning up local temp files ...")
|
|
cleanup_old_local(temp_dir, retention_days)
|
|
|
|
# Summary
|
|
success = [r for r in results if r['error'] is None]
|
|
failed = [r for r in results if r['error'] is not None]
|
|
|
|
print(f"\n╔══════════════════════════════════════════╗")
|
|
print(f"║ Summary: {len(success)} OK, {len(failed)} Failed")
|
|
print(f"╚══════════════════════════════════════════╝")
|
|
for r in success:
|
|
ck = "✓" if r['config'] else "✗"
|
|
lk = "✓" if r['logs'] else "✗"
|
|
print(f" ✓ {r['tower']} ({r['site']}) — config {ck} logs {lk}")
|
|
for r in failed:
|
|
print(f" ✗ {r['tower']} ({r['site']}) — {r['error']}")
|
|
|
|
if failed:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|