Initial resurrection kit — 81 scripts, 62 references, configs, systemd units, crons, Docker compose files, Caddy config, master README

This commit is contained in:
root
2026-07-15 17:45:36 -04:00
commit ae056eaf83
197 changed files with 26127 additions and 0 deletions
+40
View File
@@ -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
+30
View File
@@ -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
+237
View File
@@ -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
+22
View File
@@ -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
+366
View File
@@ -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()