diff --git a/manage.sh b/manage.sh index b5c5a47..0534680 100755 --- a/manage.sh +++ b/manage.sh @@ -1,26 +1,44 @@ #!/bin/bash # MeshCore Analyzer — Setup & Management Helper # Usage: ./manage.sh [command] +# +# Idempotent: safe to cancel and re-run at any point. +# Each step checks what's already done and skips it. set -e CONTAINER_NAME="meshcore-analyzer" IMAGE_NAME="meshcore-analyzer" DATA_VOLUME="meshcore-data" CADDY_VOLUME="caddy-data" +STATE_FILE=".setup-state" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' +BOLD='\033[1m' NC='\033[0m' log() { echo -e "${GREEN}✓${NC} $1"; } warn() { echo -e "${YELLOW}⚠${NC} $1"; } err() { echo -e "${RED}✗${NC} $1"; } info() { echo -e "${CYAN}→${NC} $1"; } +step() { echo -e "\n${BOLD}[$1/$TOTAL_STEPS] $2${NC}"; } -# ─── Setup ──────────────────────────────────────────────────────────────── +confirm() { + read -p " $1 [y/N] " -n 1 -r + echo + [[ $REPLY =~ ^[Yy]$ ]] +} + +# State tracking — marks completed steps so re-runs skip them +mark_done() { echo "$1" >> "$STATE_FILE"; } +is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null; } + +# ─── Setup Wizard ───────────────────────────────────────────────────────── + +TOTAL_STEPS=6 cmd_setup() { echo "" @@ -29,123 +47,271 @@ cmd_setup() { echo "═══════════════════════════════════════" echo "" - # Check Docker + if [ -f "$STATE_FILE" ]; then + info "Resuming previous setup. Delete ${STATE_FILE} to start over." + echo "" + fi + + # ── Step 1: Check Docker ── + step 1 "Checking Docker" + if ! command -v docker &> /dev/null; then err "Docker is not installed." echo "" - echo "Install it with:" - echo " curl -fsSL https://get.docker.com | sh" - echo " sudo usermod -aG docker \$USER" + echo " Install it:" + echo " curl -fsSL https://get.docker.com | sh" + echo " sudo usermod -aG docker \$USER" echo "" - echo "Then log out, log back in, and run this script again." + echo " Then log out, log back in, and run ./manage.sh setup again." exit 1 fi - log "Docker found: $(docker --version | head -1)" - # Check if container already exists - if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then - warn "Container '${CONTAINER_NAME}' already exists." - read -p " Remove it and start fresh? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - docker stop "$CONTAINER_NAME" 2>/dev/null || true - docker rm "$CONTAINER_NAME" 2>/dev/null || true - log "Old container removed." - else - echo "Aborting. Use './manage.sh stop' and './manage.sh start' to manage the existing container." - exit 0 - fi + # Check if user can actually run Docker + if ! docker info &> /dev/null; then + err "Docker is installed but your user can't run it." + echo "" + echo " Fix: sudo usermod -aG docker \$USER" + echo " Then log out, log back in, and try again." + exit 1 fi - # Config - if [ ! -f config.json ]; then - cp config.example.json config.json - log "Created config.json from example." - warn "Edit config.json to set your apiKey before continuing." - echo " nano config.json" - read -p " Press Enter when done..." - else + log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')" + mark_done "docker" + + # ── Step 2: Config ── + step 2 "Configuration" + + if [ -f config.json ]; then log "config.json exists." - fi + # Sanity check the JSON + if ! python3 -c "import json; json.load(open('config.json'))" 2>/dev/null && \ + ! node -e "JSON.parse(require('fs').readFileSync('config.json'))" 2>/dev/null; then + err "config.json has invalid JSON. Fix it and re-run setup." + exit 1 + fi + log "config.json is valid JSON." + else + info "Creating config.json from example..." + cp config.example.json config.json - # Caddyfile - if [ ! -f caddy-config/Caddyfile ]; then + # Generate a random API key + if command -v openssl &> /dev/null; then + API_KEY=$(openssl rand -hex 16) + else + API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32) + fi + # Replace the placeholder API key + if command -v sed &> /dev/null; then + sed -i "s/your-secret-api-key-here/${API_KEY}/" config.json + fi + + log "Created config.json with random API key." + echo "" + echo " You can customize config.json later (map center, branding, etc)." + echo " Edit with: nano config.json" + echo "" + fi + mark_done "config" + + # ── Step 3: Domain & HTTPS ── + step 3 "Domain & HTTPS" + + if [ -f caddy-config/Caddyfile ]; then + EXISTING_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {') + if [ "$EXISTING_DOMAIN" = ":80" ]; then + log "Caddyfile exists (HTTP only, no HTTPS)." + else + log "Caddyfile exists for ${EXISTING_DOMAIN}" + fi + else mkdir -p caddy-config echo "" - read -p " Enter your domain (e.g., analyzer.example.com): " DOMAIN - if [ -z "$DOMAIN" ]; then - warn "No domain entered. Using HTTP-only (port 80, no HTTPS)." - echo ':80 { - reverse_proxy localhost:3000 -}' > caddy-config/Caddyfile - else - echo "${DOMAIN} { + echo " How do you want to handle HTTPS?" + echo "" + echo " 1) I have a domain pointed at this server (automatic HTTPS)" + echo " 2) I'll use Cloudflare Tunnel or my own proxy (HTTP only)" + echo " 3) Just HTTP for now, I'll set up HTTPS later" + echo "" + read -p " Choose [1/2/3]: " -n 1 -r + echo "" + + case $REPLY in + 1) + read -p " Enter your domain (e.g., analyzer.example.com): " DOMAIN + if [ -z "$DOMAIN" ]; then + err "No domain entered. Re-run setup to try again." + exit 1 + fi + + echo "${DOMAIN} { reverse_proxy localhost:3000 }" > caddy-config/Caddyfile - log "Caddyfile created for ${DOMAIN}" + log "Caddyfile created for ${DOMAIN}" - # Check DNS - info "Checking DNS for ${DOMAIN}..." - RESOLVED_IP=$(dig +short "$DOMAIN" 2>/dev/null | head -1) - MY_IP=$(curl -s ifconfig.me 2>/dev/null || echo "unknown") - if [ -z "$RESOLVED_IP" ]; then - warn "DNS not resolving yet. Make sure your A record points to ${MY_IP}" - warn "HTTPS provisioning will fail until DNS propagates." - elif [ "$RESOLVED_IP" = "$MY_IP" ]; then - log "DNS resolves to ${RESOLVED_IP} — matches this server." - else - warn "DNS resolves to ${RESOLVED_IP} but this server is ${MY_IP}" - warn "HTTPS may fail if the domain doesn't point here." - fi + # Validate DNS + info "Checking DNS..." + RESOLVED_IP=$(dig +short "$DOMAIN" 2>/dev/null | grep -E '^[0-9]+\.' | head -1) + MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "unknown") + + if [ -z "$RESOLVED_IP" ]; then + warn "${DOMAIN} doesn't resolve yet." + warn "Create an A record pointing to ${MY_IP}" + warn "HTTPS won't work until DNS propagates (1-60 min)." + echo "" + if ! confirm "Continue anyway?"; then + echo " Run ./manage.sh setup again when DNS is ready." + exit 0 + fi + elif [ "$RESOLVED_IP" = "$MY_IP" ]; then + log "DNS resolves correctly: ${DOMAIN} → ${MY_IP}" + else + warn "${DOMAIN} resolves to ${RESOLVED_IP} but this server is ${MY_IP}" + warn "HTTPS provisioning will fail if the domain doesn't point here." + if ! confirm "Continue anyway?"; then + echo " Fix DNS and run ./manage.sh setup again." + exit 0 + fi + fi + + # Check port 80 + if command -v curl &> /dev/null; then + if curl -s --connect-timeout 3 "http://localhost:80" &>/dev/null || \ + curl -s --connect-timeout 3 "http://${MY_IP}:80" &>/dev/null 2>&1; then + warn "Something is already listening on port 80." + warn "Stop it first: sudo systemctl stop nginx apache2" + fi + fi + ;; + 2|3) + echo ':80 { + reverse_proxy localhost:3000 +}' > caddy-config/Caddyfile + log "Caddyfile created (HTTP only on port 80)." + if [ "$REPLY" = "2" ]; then + echo " Point your Cloudflare Tunnel or proxy to this server's port 80." + fi + ;; + *) + warn "Invalid choice. Defaulting to HTTP only." + echo ':80 { + reverse_proxy localhost:3000 +}' > caddy-config/Caddyfile + ;; + esac + fi + mark_done "caddyfile" + + # ── Step 4: Build ── + step 4 "Building Docker image" + + # Check if image exists and source hasn't changed + IMAGE_EXISTS=$(docker images -q "$IMAGE_NAME" 2>/dev/null) + if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then + log "Image already built." + if confirm "Rebuild? (only needed if you updated the code)"; then + docker build -t "$IMAGE_NAME" . + log "Image rebuilt." fi else - log "caddy-config/Caddyfile exists." + info "This takes 1-2 minutes the first time..." + docker build -t "$IMAGE_NAME" . + log "Image built." fi + mark_done "build" - # Build - echo "" - info "Building Docker image..." - docker build -t "$IMAGE_NAME" . - log "Image built." + # ── Step 5: Start container ── + step 5 "Starting container" - # Run - echo "" - info "Starting container..." - docker run -d \ - --name "$CONTAINER_NAME" \ - --restart unless-stopped \ - -p 80:80 \ - -p 443:443 \ - -v "$(pwd)/config.json:/app/config.json:ro" \ - -v "$(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro" \ - -v "${DATA_VOLUME}:/app/data" \ - -v "${CADDY_VOLUME}:/data/caddy" \ - "$IMAGE_NAME" - log "Container started." + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log "Container already running." + elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + # Exists but stopped — check if it needs recreating (new image) + info "Container exists but is stopped. Starting..." + docker start "$CONTAINER_NAME" + log "Started." + else + # Determine ports + PORTS="-p 80:80 -p 443:443" + CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {') + if [ "$CADDYFILE_DOMAIN" = ":80" ]; then + PORTS="-p 80:80" + fi + + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + $PORTS \ + -v "$(pwd)/config.json:/app/config.json:ro" \ + -v "$(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro" \ + -v "${DATA_VOLUME}:/app/data" \ + -v "${CADDY_VOLUME}:/data/caddy" \ + "$IMAGE_NAME" + log "Container started." + fi + mark_done "container" + + # ── Step 6: Verify ── + step 6 "Verifying" - # Wait and check - echo "" info "Waiting for startup..." sleep 5 + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then - log "Container is running." - echo "" - DOMAIN_LINE=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {') - if [ "$DOMAIN_LINE" = ":80" ]; then - echo " Open http://$(curl -s ifconfig.me 2>/dev/null || echo 'your-server-ip')" + # Check if Node.js is responding + HEALTHY=false + for i in 1 2 3; do + if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats &>/dev/null; then + HEALTHY=true + break + fi + sleep 2 + done + + if $HEALTHY; then + log "All services running." else - echo " Open https://${DOMAIN_LINE}" + warn "Container is running but Node.js hasn't responded yet." + warn "Check logs: ./manage.sh logs" + fi + + echo "" + echo "═══════════════════════════════════════" + echo " Setup complete!" + echo "═══════════════════════════════════════" + echo "" + if [ "$CADDYFILE_DOMAIN" != ":80" ] && [ -n "$CADDYFILE_DOMAIN" ]; then + echo " 🌐 https://${CADDYFILE_DOMAIN}" + else + MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "your-server-ip") + echo " 🌐 http://${MY_IP}" fi echo "" - echo " Logs: ./manage.sh logs" - echo " Status: ./manage.sh status" - echo " Stop: ./manage.sh stop" + echo " Next steps:" + echo " • Connect an observer to start receiving packets" + echo " • Customize branding in config.json" + echo " • Set up backups: ./manage.sh backup" + echo "" + echo " Useful commands:" + echo " ./manage.sh status Check health" + echo " ./manage.sh logs View logs" + echo " ./manage.sh backup Backup database" + echo " ./manage.sh update Update to latest version" echo "" else - err "Container failed to start. Check logs:" - docker logs "$CONTAINER_NAME" 2>&1 | tail -20 + err "Container failed to start." + echo "" + echo " Check what went wrong:" + echo " docker logs ${CONTAINER_NAME}" + echo "" + echo " Common fixes:" + echo " • Invalid config.json — check JSON syntax" + echo " • Port conflict — stop other web servers" + echo " • Re-run: ./manage.sh setup" + echo "" exit 1 fi + + mark_done "verify" } # ─── Start / Stop / Restart ────────────────────────────────────────────── @@ -167,26 +333,60 @@ cmd_stop() { } cmd_restart() { - docker restart "$CONTAINER_NAME" 2>/dev/null && log "Restarted." || err "Failed." + docker restart "$CONTAINER_NAME" 2>/dev/null && log "Restarted." || err "Not running. Use './manage.sh start'." } # ─── Status ─────────────────────────────────────────────────────────────── cmd_status() { + echo "" if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then log "Container is running." echo "" - docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Status}}\t{{.Ports}}" + docker ps --filter "name=${CONTAINER_NAME}" --format " Status: {{.Status}}" + docker ps --filter "name=${CONTAINER_NAME}" --format " Ports: {{.Ports}}" echo "" - # Quick health check - info "Checking services..." - docker exec "$CONTAINER_NAME" sh -c ' - printf " Node.js: "; wget -qO- http://localhost:3000/api/stats 2>/dev/null | head -c 80 && echo " ✓" || echo "✗ not responding" - printf " Mosquitto: "; mosquitto_sub -h localhost -t "test" -C 0 -W 1 2>/dev/null && echo "✓ running" || echo "✓ running (no messages)" - ' 2>/dev/null || true + + info "Service health:" + # Node.js + if docker exec "$CONTAINER_NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then + STATS=$(docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null) + PACKETS=$(echo "$STATS" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?") + NODES=$(echo "$STATS" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?") + log " Node.js — ${PACKETS} packets, ${NODES} nodes" + else + err " Node.js — not responding" + fi + + # Mosquitto + if docker exec "$CONTAINER_NAME" pgrep mosquitto &>/dev/null; then + log " Mosquitto — running" + else + err " Mosquitto — not running" + fi + + # Caddy + if docker exec "$CONTAINER_NAME" pgrep caddy &>/dev/null; then + log " Caddy — running" + else + err " Caddy — not running" + fi + + # Disk usage + DB_SIZE=$(docker exec "$CONTAINER_NAME" du -h /app/data/meshcore.db 2>/dev/null | cut -f1) + if [ -n "$DB_SIZE" ]; then + echo "" + info "Database size: ${DB_SIZE}" + fi else err "Container is not running." + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo " Start with: ./manage.sh start" + else + echo " Set up with: ./manage.sh setup" + fi fi + echo "" } # ─── Logs ───────────────────────────────────────────────────────────────── @@ -204,16 +404,21 @@ cmd_update() { info "Rebuilding image..." docker build -t "$IMAGE_NAME" . - info "Restarting container with new image..." + # Capture the run config before removing + CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {') + PORTS="-p 80:80 -p 443:443" + if [ "$CADDYFILE_DOMAIN" = ":80" ]; then + PORTS="-p 80:80" + fi + + info "Restarting with new image..." docker stop "$CONTAINER_NAME" 2>/dev/null || true docker rm "$CONTAINER_NAME" 2>/dev/null || true - # Re-read the run flags docker run -d \ --name "$CONTAINER_NAME" \ --restart unless-stopped \ - -p 80:80 \ - -p 443:443 \ + $PORTS \ -v "$(pwd)/config.json:/app/config.json:ro" \ -v "$(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro" \ -v "${DATA_VOLUME}:/app/data" \ @@ -226,21 +431,26 @@ cmd_update() { # ─── Backup ─────────────────────────────────────────────────────────────── cmd_backup() { - DEST="${1:-./meshcore-backup-$(date +%Y%m%d-%H%M%S).db}" - DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}')/meshcore.db + DEST="${1:-./backups/meshcore-$(date +%Y%m%d-%H%M%S).db}" + mkdir -p "$(dirname "$DEST")" - if [ ! -f "$DB_PATH" ]; then - # Fallback: try docker cp + # Try volume path first, fall back to docker cp + DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db + + if [ -f "$DB_PATH" ]; then + cp "$DB_PATH" "$DEST" + elif docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then docker cp "${CONTAINER_NAME}:/app/data/meshcore.db" "$DEST" 2>/dev/null else - cp "$DB_PATH" "$DEST" + err "Can't find database. Is the container running?" + exit 1 fi if [ -f "$DEST" ]; then SIZE=$(du -h "$DEST" | cut -f1) log "Backed up to ${DEST} (${SIZE})" else - err "Backup failed — database not found." + err "Backup failed." exit 1 fi } @@ -250,6 +460,12 @@ cmd_backup() { cmd_restore() { if [ -z "$1" ]; then err "Usage: ./manage.sh restore " + # List available backups + if [ -d "./backups" ]; then + echo "" + echo " Available backups:" + ls -lh ./backups/*.db 2>/dev/null | awk '{print " " $NF " (" $5 ")"}' + fi exit 1 fi if [ ! -f "$1" ]; then @@ -257,18 +473,20 @@ cmd_restore() { exit 1 fi - warn "This will replace the current database with $1" - read -p " Continue? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." + warn "This will stop the analyzer, replace the database with $1, and restart." + if ! confirm "Continue?"; then + echo " Aborted." exit 0 fi + # Backup current DB first + info "Backing up current database first..." + cmd_backup "./backups/meshcore-pre-restore-$(date +%Y%m%d-%H%M%S).db" + docker stop "$CONTAINER_NAME" 2>/dev/null || true - DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}')/meshcore.db - if [ -d "$(dirname "$DB_PATH")" ]; then + DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db + if [ -f "$DB_PATH" ] || [ -d "$(dirname "$DB_PATH")" ]; then cp "$1" "$DB_PATH" else docker cp "$1" "${CONTAINER_NAME}:/app/data/meshcore.db" @@ -281,13 +499,44 @@ cmd_restore() { # ─── MQTT Test ──────────────────────────────────────────────────────────── cmd_mqtt_test() { - info "Listening for MQTT messages (10 second timeout)..." - docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null - if [ $? -eq 0 ]; then - log "MQTT is receiving data." - else - warn "No MQTT messages received in 10 seconds. Is an observer connected?" + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + err "Container not running. Start with: ./manage.sh start" + exit 1 fi + + info "Listening for MQTT messages (10 second timeout)..." + MSG=$(docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null) + if [ -n "$MSG" ]; then + log "Received MQTT message:" + echo " $MSG" | head -c 200 + echo "" + else + warn "No messages received in 10 seconds." + echo "" + echo " This means no observer is publishing packets." + echo " See the deployment guide for connecting observers." + fi +} + +# ─── Reset ──────────────────────────────────────────────────────────────── + +cmd_reset() { + echo "" + warn "This will remove the container, image, and setup state." + warn "Your config.json, Caddyfile, and data volume are NOT deleted." + echo "" + if ! confirm "Continue?"; then + echo " Aborted." + exit 0 + fi + + docker stop "$CONTAINER_NAME" 2>/dev/null || true + docker rm "$CONTAINER_NAME" 2>/dev/null || true + docker rmi "$IMAGE_NAME" 2>/dev/null || true + rm -f "$STATE_FILE" + + log "Reset complete. Run './manage.sh setup' to start over." + echo " Data volume preserved. To delete it: docker volume rm ${DATA_VOLUME}" } # ─── Help ───────────────────────────────────────────────────────────────── @@ -298,18 +547,22 @@ cmd_help() { echo "" echo "Usage: ./manage.sh " echo "" - echo "Commands:" - echo " setup Interactive first-time setup (config, build, run)" - echo " start Start the container" - echo " stop Stop the container" - echo " restart Restart the container" - echo " status Show container status and service health" - echo " logs [N] Follow logs (last N lines, default 100)" - echo " update Pull latest code, rebuild, restart (preserves data)" - echo " backup [f] Backup database to file (default: timestamped)" - echo " restore Restore database from backup file" - echo " mqtt-test Listen for MQTT messages (10s timeout)" - echo " help Show this help" + echo " ${BOLD}Setup${NC}" + echo " setup First-time setup wizard (safe to re-run)" + echo " reset Remove container + image (keeps data + config)" + echo "" + echo " ${BOLD}Run${NC}" + echo " start Start the container" + echo " stop Stop the container" + echo " restart Restart the container" + echo " status Show health, stats, and service status" + echo " logs [N] Follow logs (last N lines, default 100)" + echo "" + echo " ${BOLD}Maintain${NC}" + echo " update Pull latest code, rebuild, restart (keeps data)" + echo " backup [f] Backup database (default: ./backups/timestamped)" + echo " restore Restore database from backup (backs up current first)" + echo " mqtt-test Check if MQTT data is flowing" echo "" } @@ -326,5 +579,6 @@ case "${1:-help}" in backup) cmd_backup "$2" ;; restore) cmd_restore "$2" ;; mqtt-test) cmd_mqtt_test ;; + reset) cmd_reset ;; help|*) cmd_help ;; esac