#!/bin/bash # Script to backup peers from all running nodes # Can be run as a cronjob to periodically backup peer lists # Usage: ./backup-peers.sh [backup-directory] [--verbose] BASEPATH="$(dirname "$0")" source $BASEPATH/.env # Parse arguments VERBOSE=false BACKUP_DIR="" for arg in "$@"; do case "$arg" in --verbose|-v) VERBOSE=true ;; --help|-h) echo "Usage: $0 [backup-directory] [--verbose|-v]" echo "" echo " backup-directory: Optional. Directory to store backups (default: ./peer-backups)" echo " --verbose, -v: Enable verbose output" exit 0 ;; *) if [ -z "$BACKUP_DIR" ] && [[ ! "$arg" =~ ^- ]]; then BACKUP_DIR="$arg" fi ;; esac done # Default backup directory if not provided if [ -z "$BACKUP_DIR" ]; then BACKUP_DIR="$BASEPATH/peer-backups" fi # Create backup directory if it doesn't exist mkdir -p "$BACKUP_DIR" # Timestamp for this backup run TIMESTAMP=$(date +%Y%m%d_%H%M%S) # Blacklist for compose files (same as show-status.sh) blacklist=( "drpc.yml" "drpc-free.yml" "drpc-home.yml" # dshackles "arbitrum-one-mainnet-arbnode-archive-trace.yml" # always behind and no reference rpc "ethereum-beacon-mainnet-lighthouse-pruned-blobs" # can't handle beacon rest api yet "rpc.yml" "monitoring.yml" "ftp.yml" "backup-http.yml" "base.yml" # no rpcs ) # Path blacklist (read from file if it exists) path_blacklist=() if [ -f "$BASEPATH/path-blacklist.txt" ]; then while IFS= read -r line; do if [ -n "$line" ]; then path_blacklist+=("$line") fi done < "$BASEPATH/path-blacklist.txt" fi # Protocol and domain settings if [ -n "$NO_SSL" ]; then PROTO="http" DOMAIN="${DOMAIN:-0.0.0.0}" else PROTO="https" # For HTTPS, DOMAIN should be set if [ -z "$DOMAIN" ]; then echo "Error: DOMAIN variable not found in $BASEPATH/.env" >&2 echo "Please set DOMAIN in your .env file" >&2 exit 1 fi fi # Function to extract RPC paths from a compose file extract_rpc_paths() { local compose_file="$1" local full_path="$BASEPATH/${compose_file}" if [ ! -f "$full_path" ]; then return 1 fi # Extract paths using grep (same method as peer-count.sh) # Try Perl regex first, fallback to extended regex if -P is not supported pathlist=$(cat "$full_path" | grep -oP "stripprefix\.prefixes.*?/\K[^\"]+" 2>/dev/null) if [ $? -ne 0 ] || [ -z "$pathlist" ]; then # Fallback for systems without Perl regex support pathlist=$(cat "$full_path" | grep -oE "stripprefix\.prefixes[^:]*:.*?/([^\"]+)" 2>/dev/null | sed -E 's/.*\/([^"]+)/\1/' | grep -v '^$') fi if [ -z "$pathlist" ]; then return 1 fi echo "$pathlist" } # Function to check if a path should be included should_include_path() { local path="$1" # Always exclude paths ending with /node (consensus client endpoints) if [[ "$path" =~ /node$ ]]; then if [ "$VERBOSE" = true ]; then echo " Path $path excluded: ends with /node" fi return 1 fi for word in "${path_blacklist[@]}"; do # Unescape the pattern (handle \-node -> -node) pattern=$(echo "$word" | sed 's/\\-/-/g') # Use -- to prevent grep from interpreting pattern as options if echo "$path" | grep -qE -- "$pattern"; then if [ "$VERBOSE" = true ]; then echo " Path $path matches blacklist pattern: $word" fi return 1 fi done return 0 } # Function to backup peers from a single RPC endpoint backup_peers_from_path() { local compose_file="$1" local path="$2" local compose_name="${compose_file%.yml}" # Sanitize compose name and path for filename local safe_compose_name=$(echo "$compose_name" | sed 's/[^a-zA-Z0-9_-]/_/g') local safe_path=$(echo "$path" | sed 's|[^a-zA-Z0-9_-]|_|g') # Ensure path starts with / if [[ ! "$path" =~ ^/ ]]; then path="/$path" fi local RPC_URL="${PROTO}://${DOMAIN}${path}" # Try admin_peers first (returns detailed peer info) response=$(curl --ipv4 -L -s -X POST "$RPC_URL" \ -H "Content-Type: application/json" \ --data '{"jsonrpc":"2.0","method":"admin_peers","params":[],"id":1}' \ --max-time 10 2>/dev/null) # Check for curl errors if [ $? -ne 0 ]; then echo "✗ Failed to connect to $compose_file ($path): curl error" return 1 fi # Check if we got a valid response if echo "$response" | jq -e '.result' > /dev/null 2>&1; then peer_count=$(echo "$response" | jq -r '.result | length') if [ "$peer_count" -gt 0 ]; then # Extract enodes enodes=$(echo "$response" | jq -r '.result[].enode' 2>/dev/null | grep -v '^$' | grep -v '^null$') if [ -n "$enodes" ]; then # Create backup file local backup_file="$BACKUP_DIR/${safe_compose_name}__${safe_path}__${TIMESTAMP}.json" # Create JSON structure with metadata { echo "{" echo " \"compose_file\": \"$compose_file\"," echo " \"rpc_path\": \"$path\"," echo " \"rpc_url\": \"$RPC_URL\"," echo " \"timestamp\": \"$TIMESTAMP\"," echo " \"peer_count\": $peer_count," echo " \"peers\": [" # Write enodes as JSON array first=true while IFS= read -r enode; do if [ -z "$enode" ] || [ "$enode" = "null" ]; then continue fi if [ "$first" = true ]; then first=false else echo "," fi # Escape the enode string for JSON escaped_enode=$(echo "$enode" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') echo -n " \"$escaped_enode\"" done <<< "$enodes" echo "" echo " ]" echo "}" } > "$backup_file" # Also create a simple text file with just enodes (one per line) for easy playback local backup_txt_file="$BACKUP_DIR/${safe_compose_name}__${safe_path}__${TIMESTAMP}.txt" echo "$enodes" > "$backup_txt_file" # Extract just the filename for display backup_filename=$(basename "$backup_file" 2>/dev/null || echo "${backup_file##*/}") echo "✓ Backed up $peer_count peer(s) from $compose_file ($path) to $backup_filename" return 0 fi else if [ "$VERBOSE" = true ]; then echo "⚠ No peers found for $compose_file ($path)" fi return 2 # Return 2 for "no peers" (not a failure, just nothing to backup) fi else # Check if this is a method not found error (consensus client or admin API disabled) error_code=$(echo "$response" | jq -r '.error.code // empty' 2>/dev/null) error_message=$(echo "$response" | jq -r '.error.message // empty' 2>/dev/null) if [ -n "$error_code" ] && [ "$error_code" != "null" ]; then # Check if it's a method not found error (likely consensus client) if [ "$error_code" = "-32601" ] || [ "$error_code" = "32601" ]; then # Method not found - likely consensus client, skip silently return 1 else # Other error echo "✗ $compose_file ($path): RPC error $error_code - ${error_message:-unknown error}" return 1 fi fi # Try net_peerCount as fallback (but we can't get enodes from this) response=$(curl --ipv4 -L -s -X POST "$RPC_URL" \ -H "Content-Type: application/json" \ --data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}' \ --max-time 10 2>/dev/null) if echo "$response" | jq -e '.result' > /dev/null 2>&1; then hex_value=$(echo "$response" | jq -r '.result') # Convert hex to decimal (net_peerCount returns hex like "0x10") peer_count=$((hex_value)) if [ "$peer_count" -gt 0 ]; then echo "⚠ $compose_file ($path) has $peer_count peer(s) but admin_peers not available (cannot backup enodes)" else echo "⚠ $compose_file ($path): no peers connected" fi else # Couldn't get peer count either if [ -z "$response" ]; then echo "✗ $compose_file ($path): no response from RPC endpoint" else echo "✗ $compose_file ($path): RPC endpoint not accessible or invalid" fi fi return 1 fi } # Main execution if [ -z "$COMPOSE_FILE" ]; then echo "Error: COMPOSE_FILE not found in $BASEPATH/.env" >&2 exit 1 fi # Split COMPOSE_FILE by colon IFS=':' read -ra parts <<< "$COMPOSE_FILE" total_backed_up=0 total_failed=0 total_skipped=0 total_no_peers=0 echo "Starting peer backup at $(date)" echo "Backup directory: $BACKUP_DIR" echo "COMPOSE_FILE contains: ${#parts[@]} compose file(s)" echo "" # Process each compose file for part in "${parts[@]}"; do # Handle compose file name - part might already have .yml or might not if [[ "$part" == *.yml ]]; then compose_file="$part" else compose_file="${part}.yml" fi # Check if file exists if [ ! -f "$BASEPATH/$compose_file" ]; then echo "⚠ Skipping $compose_file: file not found" total_skipped=$((total_skipped + 1)) continue fi # Check blacklist include=true for word in "${blacklist[@]}"; do # Use -- to prevent grep from interpreting pattern as options if echo "$compose_file" | grep -qE -- "$word"; then include=false break fi done if [ "$include" = false ]; then total_skipped=$((total_skipped + 1)) continue fi # Extract RPC paths from compose file paths=$(extract_rpc_paths "$compose_file") if [ -z "$paths" ]; then echo "⚠ Skipping $compose_file: no RPC paths found" total_skipped=$((total_skipped + 1)) continue fi # Process each path path_found=false # Use while loop with read to safely handle paths with spaces or special characters while IFS= read -r path || [ -n "$path" ]; do # Skip empty paths if [ -z "$path" ]; then continue fi # Check path blacklist if should_include_path "$path"; then path_found=true backup_peers_from_path "$compose_file" "$path" exit_code=$? if [ $exit_code -eq 0 ]; then total_backed_up=$((total_backed_up + 1)) elif [ $exit_code -eq 2 ]; then # No peers (not a failure) total_no_peers=$((total_no_peers + 1)) else total_failed=$((total_failed + 1)) fi else if [ "$VERBOSE" = true ]; then echo "⚠ Skipping path $path from $compose_file: blacklisted" fi fi done <<< "$paths" if [ "$path_found" = false ]; then total_skipped=$((total_skipped + 1)) fi done echo "" echo "==========================================" echo "Backup Summary" echo "==========================================" echo "Total nodes backed up: $total_backed_up" if [ $total_no_peers -gt 0 ]; then echo "Total nodes with no peers: $total_no_peers" fi echo "Total nodes failed: $total_failed" echo "Total nodes skipped: $total_skipped" echo "Backup directory: $BACKUP_DIR" echo "Completed at $(date)" echo "" # Optional: Clean up old backups (keep last 30 days) if [ -n "$CLEANUP_OLD_BACKUPS" ] && [ "$CLEANUP_OLD_BACKUPS" = "true" ]; then echo "Cleaning up backups older than 30 days..." find "$BACKUP_DIR" -name "*.json" -type f -mtime +30 -delete find "$BACKUP_DIR" -name "*.txt" -type f -mtime +30 -delete echo "Cleanup complete" fi exit 0