#!/bin/bash # # NetBird Migration Script: Pre-v0.65.0 → Combined Container Setup # # Migrates from the old 5-container deployment (dashboard, signal, relay, management, coturn) # to the new 2-container setup (Traefik + combined netbird-server). # # Supported: Embedded IdP (Dex) setups with embedded Caddy or custom reverse proxy. # Not supported: External IdP (Auth0, Keycloak, etc.) — use getting-started.sh for fresh setup. # # Usage: # ./migrate.sh [--install-dir /path/to/netbird] [--non-interactive] set -euo pipefail ############################################ # Constants ############################################ readonly SCRIPT_VERSION="1.0.0" readonly DASHBOARD_IMAGE="netbirdio/dashboard:latest" readonly NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" readonly SED_STRIP_PADDING='s/=//g' readonly MSG_SEPARATOR="==========================================" readonly PROXY_TYPE_CADDY="caddy_embedded" # Colors (disabled if not a terminal) if [[ -t 1 ]]; then readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly NC='\033[0m' else readonly RED='' readonly GREEN='' readonly YELLOW='' readonly BLUE='' readonly NC='' fi ############################################ # Global Variables (set during detection) ############################################ INSTALL_DIR="" NON_INTERACTIVE=false DOCKER_COMPOSE_CMD="" # Detection results PROXY_TYPE="" # caddy_embedded | traefik | external IDP_TYPE="" # embedded | external MGMT_VOLUME="" # detected management volume name DOMAIN="" LETSENCRYPT_EMAIL="" STORE_ENGINE="sqlite" STORE_DSN="" ENCRYPTION_KEY="" RELAY_SECRET="" SIGNKEY_REFRESH="true" TRUSTED_PROXIES="" TRUSTED_PROXIES_COUNT="" TRUSTED_PEERS="" MANAGEMENT_JSON_PATH="" BACKUP_DIR="" ############################################ # Utility Functions ############################################ log_info() { local msg="$1" echo -e "${BLUE}[INFO]${NC} ${msg}" return 0 } log_warn() { local msg="$1" echo -e "${YELLOW}[WARN]${NC} ${msg}" return 0 } log_error() { local msg="$1" echo -e "${RED}[ERROR]${NC} ${msg}" >&2 return 0 } log_success() { local msg="$1" echo -e "${GREEN}[OK]${NC} ${msg}" return 0 } print_banner() { echo "" echo "$MSG_SEPARATOR" echo " NetBird Migration Tool v${SCRIPT_VERSION}" echo " Pre-v0.65.0 → Combined Container Setup" echo "$MSG_SEPARATOR" echo "" return 0 } confirm_action() { local prompt="$1" if [[ "$NON_INTERACTIVE" == "true" ]]; then return 0 fi echo "" echo -n "$prompt [y/N]: " read -r response < /dev/tty if [[ ! "$response" =~ ^[Yy]$ ]]; then log_error "Aborted by user." exit 1 fi return 0 } ############################################ # Phase 0: Preflight & Detection ############################################ check_dependencies() { log_info "Checking dependencies..." local missing=() if ! command -v docker &>/dev/null; then missing+=("docker") fi if command -v docker-compose &>/dev/null; then DOCKER_COMPOSE_CMD="docker-compose" elif docker compose --help &>/dev/null 2>&1; then DOCKER_COMPOSE_CMD="docker compose" else missing+=("docker-compose") fi if ! command -v jq &>/dev/null; then missing+=("jq") fi if ! command -v openssl &>/dev/null; then missing+=("openssl") fi if ! command -v curl &>/dev/null; then missing+=("curl") fi if [[ ${#missing[@]} -gt 0 ]]; then log_error "Missing required dependencies: ${missing[*]}" echo "Please install them and re-run the script." exit 1 fi log_success "All dependencies found (docker compose: '$DOCKER_COMPOSE_CMD')" return 0 } detect_install_dir() { if [[ -n "$INSTALL_DIR" ]]; then if [[ ! -d "$INSTALL_DIR" ]]; then log_error "Specified install directory does not exist: $INSTALL_DIR" exit 1 fi return 0 fi log_info "Detecting installation directory..." local search_paths=("$PWD" "/opt/netbird" "/opt/wiretrustee") for dir in "${search_paths[@]}"; do if [[ -f "$dir/management.json" ]] || [[ -f "$dir/artifacts/management.json" ]]; then INSTALL_DIR="$dir" log_success "Found installation at: $INSTALL_DIR" return 0 fi done if [[ "$NON_INTERACTIVE" == "true" ]]; then log_error "Could not auto-detect installation directory. Use --install-dir to specify." exit 1 fi echo "" echo -n "Enter the path to your NetBird installation directory: " read -r INSTALL_DIR < /dev/tty if [[ ! -d "$INSTALL_DIR" ]]; then log_error "Directory does not exist: $INSTALL_DIR" exit 1 fi return 0 } validate_old_setup() { log_info "Validating old setup..." # Find management.json — check both root and artifacts/ if [[ -f "$INSTALL_DIR/management.json" ]]; then MANAGEMENT_JSON_PATH="$INSTALL_DIR/management.json" elif [[ -f "$INSTALL_DIR/artifacts/management.json" ]]; then MANAGEMENT_JSON_PATH="$INSTALL_DIR/artifacts/management.json" else log_error "Cannot find management.json in $INSTALL_DIR or $INSTALL_DIR/artifacts/" echo "This doesn't appear to be a valid NetBird installation." exit 1 fi # Check for docker-compose.yml (in root or artifacts/) local compose_found=false if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then compose_found=true elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then compose_found=true fi if [[ "$compose_found" != "true" ]]; then log_error "Cannot find docker-compose.yml in $INSTALL_DIR or $INSTALL_DIR/artifacts/" exit 1 fi log_success "Found management.json at: $MANAGEMENT_JSON_PATH" return 0 } check_already_migrated() { if [[ -f "$INSTALL_DIR/config.yaml" ]]; then log_warn "config.yaml already exists in $INSTALL_DIR" echo "It appears this installation has already been migrated." echo "If you want to re-run the migration, remove config.yaml first." exit 0 fi return 0 } detect_reverse_proxy() { log_info "Detecting reverse proxy type..." local compose_file="" if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then compose_file="$INSTALL_DIR/docker-compose.yml" elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" fi # Check for Traefik service or labels if grep -q 'traefik' "$compose_file" 2>/dev/null; then PROXY_TYPE="traefik" log_info "Detected: Traefik reverse proxy" return 0 fi # Check for embedded Caddy — two patterns: # 1. Old configure.sh: dashboard container with LETSENCRYPT_DOMAIN env var + ports 80/443 # 2. v0.62+ getting-started.sh: Caddy service in compose or standalone Caddyfile if grep -q 'LETSENCRYPT_DOMAIN' "$compose_file" 2>/dev/null && { grep -q '443:443' "$compose_file" 2>/dev/null || grep -q '443:' "$compose_file" 2>/dev/null; }; then PROXY_TYPE="$PROXY_TYPE_CADDY" log_info "Detected: Embedded Caddy (dashboard container with Let's Encrypt)" return 0 fi # Check for Caddy service in docker-compose.yml (v0.62+ pattern) if grep -qE '^\s+caddy:|^\s+image:.*caddy' "$compose_file" 2>/dev/null; then PROXY_TYPE="$PROXY_TYPE_CADDY" log_info "Detected: Caddy reverse proxy (in Docker Compose)" return 0 fi # Check for standalone Caddyfile in install directory (v0.62+ getting-started.sh) if [[ -f "$INSTALL_DIR/Caddyfile" ]]; then # Verify Caddy is referenced in docker-compose.yml or running as a container if grep -q 'caddy' "$compose_file" 2>/dev/null || grep -q 'Caddyfile' "$compose_file" 2>/dev/null; then PROXY_TYPE="$PROXY_TYPE_CADDY" log_info "Detected: Caddy reverse proxy (Caddyfile + Docker Compose)" return 0 fi # Caddyfile exists but not in compose — might be running on host PROXY_TYPE="$PROXY_TYPE_CADDY" log_info "Detected: Caddy reverse proxy (standalone Caddyfile)" return 0 fi # Check for disabled Let's Encrypt (external proxy) if [[ -f "$INSTALL_DIR/setup.env" ]] && grep -q 'NETBIRD_DISABLE_LETSENCRYPT=true' "$INSTALL_DIR/setup.env" 2>/dev/null; then PROXY_TYPE="external" log_info "Detected: External reverse proxy (Let's Encrypt disabled)" return 0 fi # Default to external PROXY_TYPE="external" log_info "Detected: External/custom reverse proxy" return 0 } detect_idp_type() { log_info "Detecting identity provider type..." # Check for embedded IdP (v0.62.0+ getting-started.sh format) local embedded_enabled embedded_enabled=$(jq -r '.EmbeddedIdP.Enabled // false' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "false") if [[ "$embedded_enabled" == "true" ]]; then IDP_TYPE="embedded" log_success "IdP type: embedded (suitable for migration)" return 0 fi # Check IdpManagerConfig.ManagerType (old configure.sh format) local manager_type manager_type=$(jq -r '.IdpManagerConfig.ManagerType // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -n "$manager_type" && "$manager_type" != "null" && "$manager_type" != "none" && "$manager_type" != "" ]]; then IDP_TYPE="external" log_error "External IdP detected: $manager_type" echo "" echo "This migration script only supports embedded IdP setups." echo "External IdP providers (Auth0, Keycloak, Zitadel, etc.) require" echo "a fresh installation using getting-started.sh." echo "" echo "Please refer to the NetBird documentation for upgrade instructions:" echo " https://docs.netbird.io/selfhosted/getting-started" exit 1 fi # Check HttpConfig.AuthIssuer for well-known external providers local auth_issuer auth_issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -n "$auth_issuer" && "$auth_issuer" != "null" ]]; then for provider in "auth0.com" "accounts.google.com" "login.microsoftonline.com" "keycloak" "zitadel" "authentik"; do if echo "$auth_issuer" | grep -qi "$provider" 2>/dev/null; then log_error "External OIDC provider detected: $auth_issuer" echo "" echo "This migration script only supports embedded IdP setups." echo "Please use getting-started.sh for a fresh installation." exit 1 fi done fi # No embedded IdP and no external IdP detected — assume old setup without IdP manager IDP_TYPE="embedded" log_success "IdP type: embedded (suitable for migration)" return 0 } detect_volumes() { log_info "Detecting Docker volumes..." local volumes_list volumes_list=$(docker volume ls --format '{{.Name}}' 2>/dev/null || echo "") # Check for well-known volume name patterns (exact match) local volume_patterns=( "wiretrustee-mgmt" "netbird-mgmt" ) for pattern in "${volume_patterns[@]}"; do if echo "$volumes_list" | grep -q "^${pattern}$"; then MGMT_VOLUME="$pattern" log_success "Found management volume: $MGMT_VOLUME" return 0 fi done # Check compose-prefixed patterns (e.g., netbird_netbird-mgmt, infrastructure_files_netbird-mgmt) local compose_prefixed compose_prefixed=$(echo "$volumes_list" | grep -E '(netbird|wiretrustee).*mgmt' | head -n1 || echo "") if [[ -n "$compose_prefixed" ]]; then MGMT_VOLUME="$compose_prefixed" log_success "Found management volume (compose-prefixed): $MGMT_VOLUME" return 0 fi # Try to extract volume name from old docker-compose.yml local compose_file="" if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then compose_file="$INSTALL_DIR/docker-compose.yml" elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" fi if [[ -n "$compose_file" ]]; then # Look for volume mount on /var/lib/netbird in management or netbird-server service local vol_name vol_name=$(grep -E '^\s+-\s+\S+:/var/lib/netbird' "$compose_file" 2>/dev/null | head -1 | sed 's/.*- //' | sed 's/:.*//' | tr -d ' ' || echo "") if [[ -n "$vol_name" && "$vol_name" != "." && "$vol_name" != "/" ]]; then # Check if this volume exists in Docker local full_vol full_vol=$(echo "$volumes_list" | grep -F "$vol_name" | head -1 || echo "") if [[ -n "$full_vol" ]]; then MGMT_VOLUME="$full_vol" log_success "Found management volume (from compose): $MGMT_VOLUME" return 0 fi fi fi log_warn "Could not detect management volume. A new volume will be created." MGMT_VOLUME="" return 0 } detect_domain() { log_info "Detecting domain..." # Try setup.env first if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/setup.env" ]]; then DOMAIN=$(grep '^NETBIRD_DOMAIN=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") fi # Try EmbeddedIdP.Issuer (v0.62.0+ getting-started.sh format) if [[ -z "$DOMAIN" ]]; then local issuer issuer=$(jq -r '.EmbeddedIdP.Issuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -n "$issuer" && "$issuer" != "null" ]]; then DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') fi fi # Try HttpConfig.AuthIssuer (old configure.sh format) if [[ -z "$DOMAIN" ]]; then local issuer issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -n "$issuer" && "$issuer" != "null" ]]; then DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') fi fi # Try dashboard.env NETBIRD_MGMT_API_ENDPOINT if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/dashboard.env" ]]; then local endpoint endpoint=$(grep '^NETBIRD_MGMT_API_ENDPOINT=' "$INSTALL_DIR/dashboard.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") if [[ -n "$endpoint" ]]; then DOMAIN=$(echo "$endpoint" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') fi fi if [[ -z "$DOMAIN" ]]; then log_error "Could not detect domain from management.json, setup.env, or dashboard.env." exit 1 fi # Detect Let's Encrypt email from setup.env or dashboard.env LETSENCRYPT_DOMAIN if [[ -f "$INSTALL_DIR/setup.env" ]]; then LETSENCRYPT_EMAIL=$(grep '^NETBIRD_LETSENCRYPT_EMAIL=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") fi log_success "Domain: $DOMAIN" if [[ -n "$LETSENCRYPT_EMAIL" ]]; then log_success "Let's Encrypt email: $LETSENCRYPT_EMAIL" fi return 0 } detect_store_config() { log_info "Detecting store configuration..." # Engine from management.json local engine engine=$(jq -r '.StoreConfig.Engine // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -n "$engine" && "$engine" != "null" && "$engine" != "" ]]; then STORE_ENGINE="$engine" fi # DSN from environment files if [[ -f "$INSTALL_DIR/setup.env" ]]; then local pg_dsn pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") if [[ -n "$pg_dsn" ]]; then STORE_DSN="$pg_dsn" fi local mysql_dsn mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") if [[ -n "$mysql_dsn" ]]; then STORE_DSN="$mysql_dsn" fi fi # Also check base.setup.env if [[ -z "$STORE_DSN" && -f "$INSTALL_DIR/base.setup.env" ]]; then local pg_dsn pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") if [[ -n "$pg_dsn" ]]; then STORE_DSN="$pg_dsn" fi local mysql_dsn mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") if [[ -n "$mysql_dsn" ]]; then STORE_DSN="$mysql_dsn" fi fi log_success "Store engine: $STORE_ENGINE" if [[ -n "$STORE_DSN" ]]; then log_success "Store DSN: [detected]" fi return 0 } extract_config_values() { log_info "Extracting configuration from management.json..." # DataStoreEncryptionKey ENCRYPTION_KEY=$(jq -r '.DataStoreEncryptionKey // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") if [[ -z "$ENCRYPTION_KEY" || "$ENCRYPTION_KEY" == "null" ]]; then ENCRYPTION_KEY=$(openssl rand -base64 32) log_warn "No encryption key found in management.json — generated a new one." log_warn "IMPORTANT: Save this key! Without it, existing encrypted data cannot be read." echo " Encryption key: $ENCRYPTION_KEY" fi # Relay secret from management.json RELAY_SECRET=$(jq -r '.Relay.Secret // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") # Fallback: relay secret from setup.env if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/setup.env" ]]; then RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") fi # Fallback: relay secret from base.setup.env if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/base.setup.env" ]]; then RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") fi # Generate if still empty if [[ -z "$RELAY_SECRET" || "$RELAY_SECRET" == "null" ]]; then RELAY_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") log_warn "No relay secret found — generated a new one." fi # IdpSignKeyRefreshEnabled — check both HttpConfig and EmbeddedIdP locations local signkey_raw signkey_raw=$(jq -r '(.HttpConfig.IdpSignKeyRefreshEnabled // .EmbeddedIdP.SignKeyRefreshEnabled) // "true"' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "true") if [[ "$signkey_raw" == "false" ]]; then SIGNKEY_REFRESH="false" else SIGNKEY_REFRESH="true" fi # ReverseProxy settings (may not exist in v0.62+ getting-started.sh format) TRUSTED_PROXIES=$(jq -c '.ReverseProxy.TrustedHTTPProxies // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") TRUSTED_PROXIES_COUNT=$(jq -r '.ReverseProxy.TrustedHTTPProxiesCount // 0' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "0") TRUSTED_PEERS=$(jq -c '.ReverseProxy.TrustedPeers // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") log_success "Configuration values extracted" return 0 } print_detection_summary() { echo "" echo "$MSG_SEPARATOR" echo " Migration Summary" echo "$MSG_SEPARATOR" echo "" echo " Install directory: $INSTALL_DIR" echo " Domain: $DOMAIN" echo " Reverse proxy: $PROXY_TYPE" echo " Store engine: $STORE_ENGINE" if [[ -n "$STORE_DSN" ]]; then echo " Store DSN: [configured]" fi if [[ -n "$MGMT_VOLUME" ]]; then echo " Management volume: $MGMT_VOLUME" else echo " Management volume: [new volume will be created]" fi echo " Encryption key: ${ENCRYPTION_KEY:0:8}..." echo " Relay secret: ${RELAY_SECRET:0:8}..." echo "" if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then echo " Migration mode: AUTOMATIC" echo " A Traefik-based docker-compose.yml will be generated and services" echo " will be stopped and restarted automatically." else echo " Migration mode: MANUAL" echo " New config files will be generated. You will need to stop old" echo " containers, replace docker-compose.yml, and restart manually." fi echo "" return 0 } ############################################ # Phase 1: Backup ############################################ create_backup() { BACKUP_DIR="$INSTALL_DIR/backup-$(date +%Y%m%d-%H%M%S)" log_info "Creating backup at: $BACKUP_DIR" mkdir -p "$BACKUP_DIR" # Copy config files local files_to_backup=( "docker-compose.yml" "management.json" "setup.env" "base.setup.env" "turnserver.conf" "dashboard.env" ) for f in "${files_to_backup[@]}"; do if [[ -f "$INSTALL_DIR/$f" ]]; then cp "$INSTALL_DIR/$f" "$BACKUP_DIR/$f" fi done # Back up artifacts/ if it exists if [[ -d "$INSTALL_DIR/artifacts" ]]; then cp -r "$INSTALL_DIR/artifacts" "$BACKUP_DIR/artifacts" fi # Record state { echo "# NetBird migration backup state" echo "# Created: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" echo "" echo "## Docker volumes" docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E '(netbird|wiretrustee)' || echo "(none found)" echo "" echo "## Running containers" docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | grep -E '(netbird|wiretrustee|dashboard|signal|relay|management|coturn)' || echo "(none running)" } > "$BACKUP_DIR/state.txt" # Generate rollback script generate_rollback_script log_success "Backup created at: $BACKUP_DIR" return 0 } generate_rollback_script() { cat > "$BACKUP_DIR/rollback.sh" <<'ROLLBACK_HEADER' #!/bin/bash set -euo pipefail # NetBird Migration Rollback Script # Restores the pre-migration configuration and restarts old containers. ROLLBACK_HEADER cat >> "$BACKUP_DIR/rollback.sh" </dev/null; then COMPOSE_CMD="docker-compose" elif docker compose --help &>/dev/null 2>&1; then COMPOSE_CMD="docker compose" else echo "ERROR: docker compose not found" >&2 exit 1 fi echo "Stopping current containers..." \$COMPOSE_CMD down 2>/dev/null || true # Restore old config files echo "Restoring configuration files..." for f in docker-compose.yml management.json setup.env base.setup.env turnserver.conf dashboard.env; do if [[ -f "\$BACKUP_DIR/\$f" ]]; then cp "\$BACKUP_DIR/\$f" "\$INSTALL_DIR/\$f" echo " Restored: \$f" fi done # Remove new config files for f in config.yaml; do if [[ -f "\$INSTALL_DIR/\$f" ]]; then rm "\$INSTALL_DIR/\$f" echo " Removed: \$f" fi done # Restart old containers echo "Starting old containers..." cd "\$INSTALL_DIR" \$COMPOSE_CMD up -d echo "" echo "Rollback complete. Old containers are running." echo "Verify with: \$COMPOSE_CMD ps" ROLLBACK_BODY chmod +x "$BACKUP_DIR/rollback.sh" return 0 } ############################################ # Phase 3: Generate New Configuration Files ############################################ generate_config_yaml() { log_info "Generating config.yaml..." local dsn_line="" if [[ -n "$STORE_DSN" ]]; then dsn_line=" dsn: \"$STORE_DSN\"" fi local reverse_proxy_section="" # Only add reverseProxy if there are non-default values local has_proxy_config=false if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then has_proxy_config=true fi if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then has_proxy_config=true fi if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then # Check if it's only the default ["0.0.0.0/0"] local default_peers='["0.0.0.0/0"]' if [[ "$TRUSTED_PEERS" != "$default_peers" ]]; then has_proxy_config=true fi fi if [[ "$has_proxy_config" == "true" ]]; then reverse_proxy_section=" reverseProxy:" if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then reverse_proxy_section+=" trustedHTTPProxies:" for proxy in $(echo "$TRUSTED_PROXIES" | jq -r '.[]' 2>/dev/null); do reverse_proxy_section+=" - \"$proxy\"" done fi if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then reverse_proxy_section+=" trustedHTTPProxiesCount: $TRUSTED_PROXIES_COUNT" fi if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then reverse_proxy_section+=" trustedPeers:" for peer in $(echo "$TRUSTED_PEERS" | jq -r '.[]' 2>/dev/null); do reverse_proxy_section+=" - \"$peer\"" done fi fi { cat < "$INSTALL_DIR/config.yaml" log_success "Generated config.yaml" return 0 } generate_dashboard_env() { log_info "Generating dashboard.env..." cat > "$INSTALL_DIR/dashboard.env" < "$INSTALL_DIR/docker-compose.yml" < "$INSTALL_DIR/docker-compose.yml" </dev/null) || true log_success "Old containers stopped" return 0 } start_new_services() { log_info "Starting new containers..." (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD up -d) log_success "New containers started" return 0 } wait_for_health() { log_info "Waiting for services to become healthy..." local max_attempts=60 local attempt=0 set +e echo -n " Checking" while [[ $attempt -lt $max_attempts ]]; do # Try OIDC endpoint through reverse proxy if curl -sk -f -o /dev/null "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null; then echo " done" set -e log_success "Services are healthy" return 0 fi # Also try health check endpoint directly if curl -sk -f -o /dev/null "http://127.0.0.1:9000/" 2>/dev/null; then echo " done" set -e log_success "Services are healthy (via healthcheck)" return 0 fi echo -n " ." sleep 2 attempt=$((attempt + 1)) if [[ $attempt -eq 30 ]]; then echo "" log_warn "Taking longer than expected. Checking container logs..." (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD logs --tail=10 netbird-server 2>/dev/null) || true echo -n " Still checking" fi done echo "" set -e log_warn "Health check timed out after $((max_attempts * 2)) seconds." log_warn "Services may still be starting. Check with: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs" return 0 } ############################################ # Phase 5: Verification & Summary ############################################ verify_migration() { log_info "Running verification checks..." local checks_passed=0 local checks_total=3 # Check 1: Container health local running running=$(cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD ps --format '{{.Name}}' 2>/dev/null | wc -l || echo "0") if [[ "$running" -ge 2 ]]; then log_success "Containers are running ($running services)" checks_passed=$((checks_passed + 1)) else log_warn "Expected at least 2 running containers, found $running" fi # Check 2: OIDC endpoint local oidc_status oidc_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null || echo "000") if [[ "$oidc_status" == "200" ]]; then log_success "OIDC endpoint responding (HTTP $oidc_status)" checks_passed=$((checks_passed + 1)) else log_warn "OIDC endpoint returned HTTP $oidc_status (expected 200)" fi # Check 3: Management API (expect 401 = working but needs auth, not 502 = proxy error) local api_status api_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/api/accounts" 2>/dev/null || echo "000") if [[ "$api_status" == "401" || "$api_status" == "200" || "$api_status" == "403" ]]; then log_success "Management API responding (HTTP $api_status)" checks_passed=$((checks_passed + 1)) else log_warn "Management API returned HTTP $api_status (expected 401/200/403)" fi echo "" echo " Verification: $checks_passed/$checks_total checks passed" return 0 } print_summary() { echo "" echo "$MSG_SEPARATOR" echo " Migration Complete" echo "$MSG_SEPARATOR" echo "" if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then echo " What was done:" echo " - Old 5-container setup stopped" echo " - New config.yaml generated (combined server config)" echo " - New dashboard.env generated (embedded IdP)" echo " - New docker-compose.yml generated (Traefik + combined server)" echo " - New containers started" else echo " What was done:" echo " - New config.yaml generated (combined server config)" echo " - New dashboard.env generated (embedded IdP)" echo " - New docker-compose.yml generated (exposed ports)" echo "" echo " What you need to do:" echo " 1. Stop old containers:" echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD down" echo "" echo " 2. Start new containers:" echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD up -d" echo "" echo " 3. Update your reverse proxy to route:" echo " - /signalexchange.SignalExchange/* -> 127.0.0.1:8081 (gRPC/h2c)" echo " - /management.ManagementService/* -> 127.0.0.1:8081 (gRPC/h2c)" echo " - /relay*, /ws-proxy/* -> 127.0.0.1:8081 (WebSocket)" echo " - /api/*, /oauth2/* -> 127.0.0.1:8081 (HTTP)" echo " - /* -> 127.0.0.1:8080 (dashboard)" fi echo "" echo " Backup location: $BACKUP_DIR" echo " Rollback command: bash $BACKUP_DIR/rollback.sh" echo "" echo " IMPORTANT:" echo " - Existing peers, routes, and policies are preserved in the database." echo " - The embedded IdP data is preserved in the management volume." echo " - Clients should reconnect automatically; if not: netbird down && netbird up" echo "" echo " Next steps:" echo " - Access the dashboard: https://$DOMAIN" echo " - Re-authenticate all clients: netbird down && netbird up" echo " - Check logs: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs -f" echo "" return 0 } ############################################ # Main ############################################ main() { # Parse arguments while [[ $# -gt 0 ]]; do local arg="$1" case "$arg" in --install-dir) local dir_value="$2" INSTALL_DIR="$dir_value" shift 2 ;; --non-interactive) NON_INTERACTIVE=true shift ;; --help|-h) echo "Usage: $0 [--install-dir /path/to/netbird] [--non-interactive]" echo "" echo "Migrates a pre-v0.65.0 NetBird deployment to the combined container setup." echo "" echo "Options:" echo " --install-dir DIR Path to existing NetBird installation" echo " --non-interactive Skip confirmation prompts (for automation)" echo " -h, --help Show this help message" exit 0 ;; *) log_error "Unknown option: $arg" echo "Use --help for usage information." exit 1 ;; esac done print_banner # Phase 0: Preflight & Detection check_dependencies detect_install_dir validate_old_setup check_already_migrated detect_reverse_proxy detect_idp_type detect_volumes detect_domain detect_store_config extract_config_values print_detection_summary confirm_action "Proceed with migration?" # Phase 1: Backup create_backup # Phase 4: Apply migration if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then # Stop old containers BEFORE overwriting docker-compose.yml stop_old_services # Phase 2 + 3: Generate new configuration files generate_config_yaml generate_dashboard_env generate_docker_compose start_new_services sleep 3 wait_for_health # Phase 5: Verification verify_migration else # For manual proxy setups, just generate files (don't stop/start) generate_config_yaml generate_dashboard_env generate_docker_compose fi print_summary return 0 } main "$@"