diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh new file mode 100755 index 000000000..67895fab6 --- /dev/null +++ b/infrastructure_files/migrate.sh @@ -0,0 +1,1286 @@ +#!/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 "$@"