Initial resurrection kit — 81 scripts, 62 references, configs, systemd units, crons, Docker compose files, Caddy config, master README
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# WISP Daily Backup Configuration
|
||||
# ==============================
|
||||
|
||||
vpn:
|
||||
# L2TP/IPsec connection to your home MikroTik gateway
|
||||
server_ip: "76.195.7.60"
|
||||
psk: "N2dinside1520!"
|
||||
username: "shonuff" # L2TP username
|
||||
password: "The.glow.1985" # L2TP password
|
||||
interface: "ppp0"
|
||||
|
||||
# For PoC with home router, we don't need tower routes yet.
|
||||
# Routes added here once we expand to tower CCRs.
|
||||
routes: []
|
||||
|
||||
s3:
|
||||
bucket: "mikrotik-ccr-backups" # Wasabi bucket name
|
||||
region: "us-east-1"
|
||||
prefix: "wisp-backups"
|
||||
endpoint: "https://s3.us-east-1.wasabisys.com"
|
||||
# Credentials in ~/.aws/credentials
|
||||
|
||||
towers:
|
||||
# Proof of concept: just your home router
|
||||
- name: "home-gateway"
|
||||
ip: "10.77.0.2"
|
||||
site: "home"
|
||||
ssh_port: 22
|
||||
ssh_user: "shonuff"
|
||||
ssh_key: "/root/.ssh/wisp_rsa"
|
||||
ssh_password: ""
|
||||
|
||||
# Future towers go here
|
||||
|
||||
backup:
|
||||
log_lines: 500
|
||||
temp_dir: "/root/.hermes/.backups/wisp-backups"
|
||||
compress: true
|
||||
retention_days: 7
|
||||
timeout_seconds: 60
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# wisp-vpn-keepalive.sh — Check VPN status and reconnect if down
|
||||
# Designed to run as a systemd timer every 5 minutes
|
||||
|
||||
VPN_SCRIPT="/root/.hermes/scripts/wisp-backup/home-router-vpn.sh"
|
||||
STATUS_FILE="/tmp/wisp-vpn-status"
|
||||
|
||||
# Check if VPN is up
|
||||
if "$VPN_SCRIPT" status >/dev/null 2>&1; then
|
||||
# VPN is up — write OK timestamp
|
||||
date +%s > "$STATUS_FILE"
|
||||
exit 0
|
||||
else
|
||||
# VPN is down — attempt reconnect
|
||||
LAST_OK=$(cat "$STATUS_FILE" 2>/dev/null || echo "0")
|
||||
NOW=$(date +%s)
|
||||
ELAPSED=$((NOW - LAST_OK))
|
||||
|
||||
echo "[-] WISP VPN is DOWN (was up ${ELAPSED}s ago). Attempting reconnect..."
|
||||
"$VPN_SCRIPT" up 2>&1
|
||||
|
||||
if "$VPN_SCRIPT" status >/dev/null 2>&1; then
|
||||
echo "[+] WISP VPN reconnected successfully"
|
||||
date +%s > "$STATUS_FILE"
|
||||
exit 0
|
||||
else
|
||||
echo "[-] WISP VPN reconnect failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/bin/bash
|
||||
# wisp-vpn.sh — Connect/disconnect L2TP/IPsec VPN to MicroTik gateway
|
||||
# Usage: wisp-vpn.sh [up|down|status]
|
||||
|
||||
CONFIG_DIR="$(dirname "$0")"
|
||||
YAML="$CONFIG_DIR/config.yaml"
|
||||
|
||||
# Parse YAML values
|
||||
get_cfg() {
|
||||
local key="$1"
|
||||
grep -A50 "^vpn:" "$YAML" | grep "^ $key:" | sed 's/.*: //' | tr -d '"' | sed 's/ *#.*//' | head -1
|
||||
}
|
||||
|
||||
SERVER_IP=$(get_cfg server_ip)
|
||||
PSK=$(get_cfg psk)
|
||||
USERNAME=$(get_cfg username)
|
||||
PASSWORD=$(get_cfg password)
|
||||
IFACE=$(get_cfg interface)
|
||||
IFACE="${IFACE:-ppp0}"
|
||||
|
||||
L2TP_NAME="wisp-vpn"
|
||||
IPSEC_CONF="/etc/ipsec.conf"
|
||||
IPSEC_SECRETS="/etc/ipsec.secrets"
|
||||
XL2TPD_CONF="/etc/xl2tpd/xl2tpd.conf"
|
||||
L2TP_PEERS="/etc/ppp/peers/$L2TP_NAME"
|
||||
L2TP_SECRETS="/etc/ppp/chap-secrets"
|
||||
|
||||
case "$1" in
|
||||
up)
|
||||
echo "[*] Bringing up L2TP/IPsec VPN to $SERVER_IP ..."
|
||||
|
||||
# Write ipsec.conf for strongSwan compatibility
|
||||
cat > "$IPSEC_CONF" <<IPSEOF
|
||||
config setup
|
||||
charondebug="all"
|
||||
uniqueids=yes
|
||||
conn $L2TP_NAME
|
||||
auto=add
|
||||
keyexchange=ikev1
|
||||
authby=secret
|
||||
type=transport
|
||||
left=%defaultroute
|
||||
leftprotoport=17/1701
|
||||
right=$SERVER_IP
|
||||
rightprotoport=17/1701
|
||||
ike=aes128-sha1-modp1024
|
||||
esp=aes128-sha1-modp1024
|
||||
dpddelay=30
|
||||
dpdtimeout=120
|
||||
dpdaction=clear
|
||||
keyingtries=%forever
|
||||
ikelifetime=24h
|
||||
lifetime=8h
|
||||
margin=10m
|
||||
IPSEOF
|
||||
|
||||
# Write ipsec.secrets — specify exact IPs for strongSwan
|
||||
printf '5.161.114.8 %s : PSK "%s"\n' "$SERVER_IP" "$PSK" > "$IPSEC_SECRETS"
|
||||
|
||||
# Write xl2tpd.conf
|
||||
cat > "$XL2TPD_CONF" <<XL2EOF
|
||||
[global]
|
||||
port = 1701
|
||||
[lac $L2TP_NAME]
|
||||
lns = $SERVER_IP
|
||||
ppp debug = yes
|
||||
pppoptfile = /etc/ppp/options.$L2TP_NAME
|
||||
length bit = yes
|
||||
require pap = no
|
||||
require chap = yes
|
||||
autodial = yes
|
||||
redial = yes
|
||||
redial timeout = 5
|
||||
max redials = 3
|
||||
XL2EOF
|
||||
|
||||
# Write ppp options — NO replacedefaultroute (keeps internet on eth0)
|
||||
# Also: no defaultroute at all — we'll add the necessary routes manually
|
||||
# after the connection is established to avoid ppp0 recursion.
|
||||
cat > "/etc/ppp/options.$L2TP_NAME" <<PPPOEOF
|
||||
ipcp-accept-local
|
||||
ipcp-accept-remote
|
||||
refuse-eap
|
||||
noccp
|
||||
noauth
|
||||
idle 0
|
||||
maxfail 3
|
||||
debug
|
||||
name $USERNAME
|
||||
password $PASSWORD
|
||||
mtu 1400
|
||||
mru 1400
|
||||
usepeerdns
|
||||
lcp-echo-interval 10
|
||||
lcp-echo-failure 5
|
||||
PPPOEOF
|
||||
|
||||
# Write or update chap-secrets entry
|
||||
if grep -q "^\"$USERNAME\"" "$L2TP_SECRETS" 2>/dev/null; then
|
||||
# Entry exists — remove old line and add new one (password may have changed)
|
||||
sed -i "/^\"$USERNAME\"/d" "$L2TP_SECRETS" 2>/dev/null
|
||||
fi
|
||||
echo "\"$USERNAME\" * \"$PASSWORD\" *" >> "$L2TP_SECRETS"
|
||||
|
||||
# Ensure the L2TP server IP has a clean route through eth0 BEFORE ipsec starts
|
||||
# This prevents IPsec + pppd from creating a routing loop (ppp0 recursion)
|
||||
CURRENT_GW=$(ip route get 8.8.8.8 | awk '{print $3}' | head -1)
|
||||
if [ -n "$CURRENT_GW" ]; then
|
||||
ip route add "$SERVER_IP" via "$CURRENT_GW" dev eth0 metric 100 2>/dev/null || \
|
||||
ip route change "$SERVER_IP" via "$CURRENT_GW" dev eth0 metric 100 2>/dev/null
|
||||
echo "[route] $SERVER_IP via $CURRENT_GW (avoids ppp0 recursion)"
|
||||
fi
|
||||
|
||||
# Reload config — loads new connections added to ipsec.conf
|
||||
ipsec reload 2>/dev/null
|
||||
sleep 2
|
||||
ipsec up $L2TP_NAME 2>/dev/null
|
||||
sleep 3
|
||||
|
||||
systemctl restart xl2tpd 2>/dev/null
|
||||
sleep 3
|
||||
|
||||
# Trigger the connection
|
||||
echo "c $L2TP_NAME" > /var/run/xl2tpd/l2tp-control 2>/dev/null
|
||||
sleep 5
|
||||
|
||||
# Verify
|
||||
if ip addr show "$IFACE" 2>/dev/null | grep -q "inet "; then
|
||||
LOCAL_IP=$(ip addr show "$IFACE" | grep "inet " | awk '{print $2}')
|
||||
echo "[+] VPN connected! Interface: $IFACE, IP: $LOCAL_IP"
|
||||
|
||||
# Add route to peer's LAN subnet through ppp0 (if the peer address is known)
|
||||
# Without this, ppp0 recursion happens when pppd applies defaultroute
|
||||
PEER_IP=$(echo "$LOCAL_IP" | cut -d' ' -f1)
|
||||
echo "[*] Adding route to VPN peer's subnet via $IFACE ..."
|
||||
ip route add 192.168.88.0/24 dev "$IFACE" 2>/dev/null && \
|
||||
echo " [route] Added 192.168.88.0/24 via $IFACE" || \
|
||||
echo " [route] 192.168.88.0/24 already exists"
|
||||
|
||||
# Add static routes for routed tower subnets from config
|
||||
echo "[*] Adding static routes for tower subnets ..."
|
||||
ROUTES=$(grep -A50 "^vpn:" "$YAML" | grep -A50 "^ routes:" | grep "^- " | sed 's/.*- //' | tr -d '"')
|
||||
if [ -n "$ROUTES" ]; then
|
||||
echo "$ROUTES" | while read -r subnet; do
|
||||
if [ -n "$subnet" ]; then
|
||||
# Check if route already exists
|
||||
if ip route show "$subnet" 2>/dev/null | grep -q .; then
|
||||
echo " [route] $subnet already exists"
|
||||
else
|
||||
ip route add "$subnet" dev "$IFACE"
|
||||
echo " [route] Added $subnet via $IFACE"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " (no routes configured in config.yaml)"
|
||||
fi
|
||||
|
||||
# Safety: verify internet connectivity still works
|
||||
# If ppp's replacedefaultroute kills internet, kill ppp immediately
|
||||
echo "[*] Checking internet connectivity ..."
|
||||
if ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1 || ping -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
|
||||
echo "[+] Internet reachable — VPN is safe"
|
||||
else
|
||||
echo "[-] INTERNET LOST after VPN connect!"
|
||||
echo " Tearing down VPN to restore connectivity ..."
|
||||
echo "d $L2TP_NAME" > /var/run/xl2tpd/l2tp-control 2>/dev/null
|
||||
pkill -f "pppd.*$L2TP_NAME" 2>/dev/null
|
||||
ipsec down $L2TP_NAME 2>/dev/null
|
||||
sleep 2
|
||||
echo "[-] VPN terminated — internet should be restored"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo "[-] VPN connection failed (ppp0 not up)."
|
||||
echo " Cleaning up IPsec to avoid orphaned tunnel ..."
|
||||
echo "d $L2TP_NAME" > /var/run/xl2tpd/l2tp-control 2>/dev/null
|
||||
pkill -f "pppd.*$L2TP_NAME" 2>/dev/null
|
||||
ipsec down $L2TP_NAME 2>/dev/null
|
||||
sleep 2
|
||||
echo " Check logs: journalctl -u xl2tpd -n 20"
|
||||
echo " ipsec status"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
down)
|
||||
echo "[*] Tearing down VPN ..."
|
||||
|
||||
# Remove static routes for tower subnets
|
||||
ROUTES=$(grep -A50 "^vpn:" "$YAML" | grep -A50 "^ routes:" | grep "^- " | sed 's/.*- //' | tr -d '"')
|
||||
if [ -n "$ROUTES" ]; then
|
||||
echo "[*] Removing static routes ..."
|
||||
echo "$ROUTES" | while read -r subnet; do
|
||||
if [ -n "$subnet" ]; then
|
||||
ip route del "$subnet" dev "$IFACE" 2>/dev/null && \
|
||||
echo " [route] Removed $subnet" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "d $L2TP_NAME" > /var/run/xl2tpd/l2tp-control 2>/dev/null
|
||||
ipsec down $L2TP_NAME 2>/dev/null
|
||||
sleep 2
|
||||
# Kill the ppp interface
|
||||
pkill -f "pppd.*$L2TP_NAME" 2>/dev/null
|
||||
sleep 1
|
||||
# Safety: ensure default route via eth0 is restored
|
||||
DEFAULT_GW=$(ip route show default | grep eth0 | head -1 || true)
|
||||
if [ -z "$DEFAULT_GW" ]; then
|
||||
GW=$(ip route get 8.8.8.8 | awk '{print $3}' | head -1)
|
||||
DEV=$(ip route get 8.8.8.8 | awk '{print $5}' | head -1)
|
||||
if [ -n "$GW" ] && [ -n "$DEV" ]; then
|
||||
echo " [safety] Default route lost — restoring via $DEV"
|
||||
ip route add default via "$GW" dev "$DEV" metric 100 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
echo "[+] VPN disconnected"
|
||||
;;
|
||||
|
||||
status)
|
||||
if ip addr show "$IFACE" 2>/dev/null | grep -q "inet "; then
|
||||
LOCAL_IP=$(ip addr show "$IFACE" | grep "inet " | awk '{print $2}')
|
||||
echo "[+] VPN is UP — $IFACE: $LOCAL_IP"
|
||||
exit 0
|
||||
else
|
||||
echo "[-] VPN is DOWN"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 {up|down|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# home-router-watchdog.sh — Check home router via WireGuard tunnel
|
||||
# Silent on success (no_agent mode). Alerts only on failure.
|
||||
|
||||
TUNNEL_IP="10.77.0.2"
|
||||
PING_COUNT=6
|
||||
PING_INTERVAL=10
|
||||
SUCCESS=0
|
||||
|
||||
for i in $(seq 1 $PING_COUNT); do
|
||||
if ping -c 1 -W 5 "$TUNNEL_IP" >/dev/null 2>&1; then
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
if [ "$SUCCESS" -ge 2 ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep "$PING_INTERVAL"
|
||||
done
|
||||
|
||||
echo "[-] Home router offline — not responding through WireGuard tunnel"
|
||||
echo "[-] Router IP: $TUNNEL_IP"
|
||||
exit 1
|
||||
Executable
+366
@@ -0,0 +1,366 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user