diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 7418cb8e839..aeef10a7c51 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -12,7 +12,10 @@ SED_STRIP_PADDING='s/=//g' NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" # Static IP for Traefik inside the compose bridge network. The management -# server trusts X-Forwarded-* headers from this address only. +# server trusts X-Forwarded-* headers from this address only, so all three +# values derive from the same /24. Override with NETBIRD_DOCKER_SUBNET. +DOCKER_SUBNET="172.30.0.0/24" +DOCKER_GATEWAY="172.30.0.1" TRAEFIK_IP="172.30.0.10" check_docker_compose() { @@ -43,6 +46,142 @@ rand_b64_key() { openssl rand -base64 32 } +# ------------------------------------------------------------------ +# Docker network subnet override and conflict check +# (kept in sync with getting-started.sh; only the compose network +# name differs) +# ------------------------------------------------------------------ + +ip_to_int() { + local a b c d + IFS=. read -r a b c d <<< "$1" + echo $(( (10#$a << 24) + (10#$b << 16) + (10#$c << 8) + 10#$d )) +} + +# cidrs_overlap — succeeds if the networks overlap +cidrs_overlap() { + local net1="${1%/*}" len1="${1#*/}" net2="${2%/*}" len2="${2#*/}" + local min_len=$(( len1 < len2 ? len1 : len2 )) + local mask=0 + if [[ "$min_len" -gt 0 ]]; then + mask=$(( (0xFFFFFFFF << (32 - min_len)) & 0xFFFFFFFF )) + fi + [[ $(( $(ip_to_int "$net1") & mask )) -eq $(( $(ip_to_int "$net2") & mask )) ]] +} + +# valid_ipv4_slash24 — accepts a unicast IPv4 /24 like 10.123.45.0/24 +valid_ipv4_slash24() { + local octet='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])' + local re="^${octet}\.${octet}\.${octet}\.0/24$" + [[ "$1" =~ $re ]] || return 1 + # Reject non-unicast/reserved ranges: 0/8, loopback, link-local, 224+. + # 100.64/10 is rejected too: NetBird allocates overlay peer addresses from + # it by default, and a bridge there shadows the overlay without any Docker + # network overlapping, so the conflict check below would not catch it. + case "$1" in + 0.*|127.*|169.254.*|22[4-9].*|2[34][0-9].*|25[0-5].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;; + esac + return 0 +} + +# Apply NETBIRD_DOCKER_SUBNET and derive the gateway (.1) and Traefik IP (.10) +apply_docker_subnet_override() { + if [[ -n "${NETBIRD_DOCKER_SUBNET:-}" ]]; then + if ! valid_ipv4_slash24 "$NETBIRD_DOCKER_SUBNET"; then + echo "NETBIRD_DOCKER_SUBNET must be a unicast IPv4 /24 network like 10.123.45.0/24 (0/8, 127/8, 169.254/16, 100.64/10, and 224+ are not allowed), got: $NETBIRD_DOCKER_SUBNET" > /dev/stderr + exit 1 + fi + DOCKER_SUBNET="$NETBIRD_DOCKER_SUBNET" + fi + local base="${DOCKER_SUBNET%.0/24}" + DOCKER_GATEWAY="${base}.1" + TRAEFIK_IP="${base}.10" + return 0 +} + +# check_docker_subnet_conflicts +# Fail early if an existing Docker network overlaps DOCKER_SUBNET, instead +# of letting "docker compose up" fail later. Host routes are not checked; +# NETBIRD_DOCKER_SUBNET covers those cases. +check_docker_subnet_conflicts() { + local expected_network="$1" + if ! command -v docker &> /dev/null; then + echo "ERROR: the Docker CLI was not found in PATH." > /dev/stderr + echo "It is required to verify that $DOCKER_SUBNET is free before this install pins it." > /dev/stderr + echo "Install Docker (https://docs.docker.com/engine/install/) and run this script again." > /dev/stderr + exit 1 + fi + + # docker's own stderr is left visible on purpose: "is the daemon running" + # and socket permission errors are the actionable part. Only the exit status + # is handled here, because skipping the check silently would resurface later + # as a confusing "docker compose up" failure. + local ids_raw ls_status=0 + ids_raw="$(docker network ls -q)" || ls_status=$? + if [[ "$ls_status" -ne 0 ]]; then + echo "ERROR: could not list the existing Docker networks (docker network ls exited $ls_status)." > /dev/stderr + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." > /dev/stderr + echo "Make sure the Docker daemon is running and reachable by this user, then run this script again." > /dev/stderr + exit 1 + fi + + # Collect the IDs in an array so they reach docker as separate arguments + local network_ids=() id + while IFS= read -r id; do + if [[ -n "$id" ]]; then + network_ids+=("$id") + fi + done <<< "$ids_raw" + + # No Docker networks at all: nothing can overlap, so there is nothing to check + [[ "${#network_ids[@]}" -gt 0 ]] || return 0 + + local inspect_output inspect_status=0 + inspect_output="$(docker network inspect --format '{{.Name}}|{{range .IPAM.Config}}{{.Subnet}} {{end}}' "${network_ids[@]}")" || inspect_status=$? + if [[ "$inspect_status" -ne 0 ]]; then + echo "ERROR: could not inspect the existing Docker networks (docker network inspect exited $inspect_status)." > /dev/stderr + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." > /dev/stderr + echo "If a Docker network was removed while this script was running, run the script again." > /dev/stderr + exit 1 + fi + + local name subnets subnet + while IFS='|' read -r name subnets; do + for subnet in $subnets; do + [[ "$subnet" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || continue + if [[ "$name" == "$expected_network" ]]; then + # Our own leftover network: compose reuses it as-is, so its subnet + # must match the one we render + if [[ "$subnet" != "$DOCKER_SUBNET" ]]; then + echo "ERROR: the Docker network '$name', left over from a previous NetBird install, uses $subnet instead of $DOCKER_SUBNET." > /dev/stderr + echo "docker compose would reuse it as-is, and the generated configuration would not match it." > /dev/stderr + echo "Remove it and run this script again:" > /dev/stderr + echo " docker network rm $name" > /dev/stderr + exit 1 + fi + elif cidrs_overlap "$DOCKER_SUBNET" "$subnet"; then + echo "ERROR: the existing Docker network '$name' ($subnet) overlaps $DOCKER_SUBNET, the subnet NetBird would use." > /dev/stderr + # This script pins its own network to the literal name "netbird", which + # the branch above already handles, so a "_netbird" name here is + # a separate NetBird install (typically the community one). A different + # subnet would not help: both stacks use the same container names. + if [[ "$name" == *_netbird ]]; then + echo "That network was created by another NetBird install on this host." > /dev/stderr + echo "Remove that install first, or run this script from its directory." > /dev/stderr + echo "Find it with: docker network inspect $name" > /dev/stderr + else + echo "That network is not managed by this script and is left untouched." > /dev/stderr + echo "Pick a free /24 for NetBird instead and run this script again:" > /dev/stderr + echo " NETBIRD_DOCKER_SUBNET=10.123.45.0/24 ./getting-started-enterprise.sh" > /dev/stderr + fi + exit 1 + fi + done + done <<< "$inspect_output" + return 0 +} + check_nb_domain() { local domain="$1" if [[ -z "$domain" ]]; then @@ -224,6 +363,9 @@ wait_postgres() { init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + # Settle the subnet (and fail on conflicts) before the EULA and prompts + apply_docker_subnet_override + check_docker_subnet_conflicts "netbird" if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then echo "Generated files already exist in $(pwd)." @@ -273,6 +415,7 @@ init_environment() { echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" echo " Domain: ${NETBIRD_DOMAIN}" echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" + echo " Subnet: ${DOCKER_SUBNET} (Traefik at ${TRAEFIK_IP})" echo "" echo "Rendering files into $(pwd) ..." install -m 600 /dev/null .env @@ -334,7 +477,12 @@ NETBIRD_DOMAIN=${NETBIRD_DOMAIN} # Reverse proxy (Traefik) NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL} NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6} +# These three must stay in step with the /32 trust pins in config.yaml +# (reverseProxy.trustedPeers/trustedHTTPProxies). Shell env vars override +# this file at compose time. NETBIRD_TRAEFIK_IP=${TRAEFIK_IP} +NETBIRD_NETWORK_SUBNET=${DOCKER_SUBNET} +NETBIRD_NETWORK_GATEWAY=${DOCKER_GATEWAY} # Image tags. Default to "latest" NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest} @@ -417,6 +565,9 @@ render_compose_common() { networks: netbird: ipv4_address: ${NETBIRD_TRAEFIK_IP} + # Resolve the public domain inside this network (avoids hairpin NAT) + aliases: + - "${NETBIRD_DOMAIN}" command: # Logging - "--log.level=INFO" @@ -660,8 +811,8 @@ networks: driver: bridge ipam: config: - - subnet: 172.30.0.0/24 - gateway: 172.30.0.1 + - subnet: ${NETBIRD_NETWORK_SUBNET} + gateway: ${NETBIRD_NETWORK_GATEWAY} EOF } diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 0fc5b23c562..505aff3b89a 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -108,6 +108,23 @@ check_nb_domain() { echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr return 1 fi + + # Letters, digits, dots, and hyphens only, with every dot-separated label + # starting and ending in a letter or digit; the domain is embedded in + # generated YAML and env files. The per-label form also rejects empty labels + # ("a..b"). This is not FQDN validation: "use-ip" and bare IP addresses are + # valid inputs here and both satisfy the pattern. + local label='[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?' + local re="^${label}(\.${label})*$" + if [[ ! "$DOMAIN" =~ $re ]]; then + echo "The NETBIRD_DOMAIN may only contain letters, digits, dots, and hyphens, and each dot-separated label must begin and end with a letter or digit." > /dev/stderr + return 1 + fi + + if [[ "${#DOMAIN}" -gt 253 ]]; then + echo "The NETBIRD_DOMAIN cannot be longer than 253 characters." > /dev/stderr + return 1 + fi return 0 } @@ -130,6 +147,7 @@ check_nb_domain() { # NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) # NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) # NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) +# NETBIRD_DOCKER_SUBNET built-in Traefik /24 (default 172.30.0.0/24) # NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY # tty_available succeeds only when we may prompt: never when the operator has @@ -170,6 +188,7 @@ read_nb_domain() { read -r READ_NETBIRD_DOMAIN < /dev/tty if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then read_nb_domain + return fi echo "$READ_NETBIRD_DOMAIN" return 0 @@ -390,6 +409,160 @@ wait_management_direct() { return 0 } +############################################ +# Docker Network Subnet Override and Conflict Check +############################################ + +ip_to_int() { + local a b c d + IFS=. read -r a b c d <<< "$1" + echo $(( (10#$a << 24) + (10#$b << 16) + (10#$c << 8) + 10#$d )) +} + +# cidrs_overlap — succeeds if the networks overlap +cidrs_overlap() { + local net1="${1%/*}" len1="${1#*/}" net2="${2%/*}" len2="${2#*/}" + local min_len=$(( len1 < len2 ? len1 : len2 )) + local mask=0 + if [[ "$min_len" -gt 0 ]]; then + mask=$(( (0xFFFFFFFF << (32 - min_len)) & 0xFFFFFFFF )) + fi + [[ $(( $(ip_to_int "$net1") & mask )) -eq $(( $(ip_to_int "$net2") & mask )) ]] +} + +# valid_ipv4_slash24 — accepts a unicast IPv4 /24 like 10.123.45.0/24 +valid_ipv4_slash24() { + local octet='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])' + local re="^${octet}\.${octet}\.${octet}\.0/24$" + [[ "$1" =~ $re ]] || return 1 + # Reject non-unicast/reserved ranges: 0/8, loopback, link-local, 224+. + # 100.64/10 is rejected too: NetBird allocates overlay peer addresses from + # it by default, and a bridge there shadows the overlay without any Docker + # network overlapping, so the conflict check below would not catch it. + case "$1" in + 0.*|127.*|169.254.*|22[4-9].*|2[34][0-9].*|25[0-5].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;; + esac + return 0 +} + +# Apply NETBIRD_DOCKER_SUBNET and derive the gateway (.1) and Traefik IP (.10) +apply_docker_subnet_override() { + if [[ -n "${NETBIRD_DOCKER_SUBNET:-}" ]]; then + if ! valid_ipv4_slash24 "$NETBIRD_DOCKER_SUBNET"; then + echo "NETBIRD_DOCKER_SUBNET must be a unicast IPv4 /24 network like 10.123.45.0/24 (0/8, 127/8, 169.254/16, 100.64/10, and 224+ are not allowed), got: $NETBIRD_DOCKER_SUBNET" > /dev/stderr + exit 1 + fi + DOCKER_SUBNET="$NETBIRD_DOCKER_SUBNET" + fi + local base="${DOCKER_SUBNET%.0/24}" + DOCKER_GATEWAY="${base}.1" + TRAEFIK_IP="${base}.10" + return 0 +} + +# check_docker_subnet_conflicts +# Fail early if an existing Docker network overlaps DOCKER_SUBNET, instead +# of letting "docker compose up" fail later. Host routes are not checked; +# NETBIRD_DOCKER_SUBNET covers those cases. +check_docker_subnet_conflicts() { + local expected_network="$1" + if ! command -v docker &> /dev/null; then + echo "ERROR: the Docker CLI was not found in PATH." > /dev/stderr + echo "It is required to verify that $DOCKER_SUBNET is free before the built-in Traefik setup pins it." > /dev/stderr + echo "Install Docker (https://docs.docker.com/engine/install/) and run this script again." > /dev/stderr + exit 1 + fi + + # docker's own stderr is left visible on purpose: "is the daemon running" + # and socket permission errors are the actionable part. Only the exit status + # is handled here, because skipping the check silently would resurface later + # as a confusing "docker compose up" failure. + local ids_raw ls_status=0 + ids_raw="$(docker network ls -q)" || ls_status=$? + if [[ "$ls_status" -ne 0 ]]; then + echo "ERROR: could not list the existing Docker networks (docker network ls exited $ls_status)." > /dev/stderr + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." > /dev/stderr + echo "Make sure the Docker daemon is running and reachable by this user, then run this script again." > /dev/stderr + exit 1 + fi + + # Collect the IDs in an array so they reach docker as separate arguments + local network_ids=() id + while IFS= read -r id; do + if [[ -n "$id" ]]; then + network_ids+=("$id") + fi + done <<< "$ids_raw" + + # No Docker networks at all: nothing can overlap, so there is nothing to check + [[ "${#network_ids[@]}" -gt 0 ]] || return 0 + + local inspect_output inspect_status=0 + inspect_output="$(docker network inspect --format '{{.Name}}|{{range .IPAM.Config}}{{.Subnet}} {{end}}' "${network_ids[@]}")" || inspect_status=$? + if [[ "$inspect_status" -ne 0 ]]; then + echo "ERROR: could not inspect the existing Docker networks (docker network inspect exited $inspect_status)." > /dev/stderr + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." > /dev/stderr + echo "If a Docker network was removed while this script was running, run the script again." > /dev/stderr + exit 1 + fi + + local name subnets subnet + while IFS='|' read -r name subnets; do + for subnet in $subnets; do + [[ "$subnet" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || continue + if [[ "$name" == "$expected_network" ]]; then + # Our own leftover network: compose reuses it as-is, so its subnet + # must match the one we render + if [[ "$subnet" != "$DOCKER_SUBNET" ]]; then + echo "ERROR: the Docker network '$name', left over from a previous NetBird install, uses $subnet instead of $DOCKER_SUBNET." > /dev/stderr + echo "docker compose would reuse it as-is, and the generated configuration would not match it." > /dev/stderr + echo "Remove it and run this script again:" > /dev/stderr + echo " docker network rm $name" > /dev/stderr + exit 1 + fi + elif cidrs_overlap "$DOCKER_SUBNET" "$subnet"; then + echo "ERROR: the existing Docker network '$name' ($subnet) overlaps $DOCKER_SUBNET, the subnet NetBird would use." > /dev/stderr + # Every network this script creates is named _netbird, so that + # suffix means a NetBird install in another directory rather than an + # unrelated network. Suggesting a different subnet there would not help: + # the container names are fixed, so a second install collides regardless. + if [[ "$name" == *_netbird ]]; then + echo "That network was created by a NetBird install in a different directory." > /dev/stderr + echo "Run this script from that directory instead, or remove the old install first." > /dev/stderr + echo "Find it with: docker network inspect $name" > /dev/stderr + else + echo "That network is not managed by this script and is left untouched." > /dev/stderr + echo "Pick a free /24 for NetBird instead and run this script again:" > /dev/stderr + echo " NETBIRD_DOCKER_SUBNET=10.123.45.0/24 ./getting-started.sh" > /dev/stderr + fi + exit 1 + fi + done + done <<< "$inspect_output" + return 0 +} + +configure_docker_subnet() { + # Only the built-in Traefik mode pins a subnet; other modes let Docker pick + if [[ "$REVERSE_PROXY_TYPE" != "0" ]]; then + return 0 + fi + + # Skip our own network (_netbird) in the conflict check. Compose + # derives the project name from the basename of the logical working directory + # (verified against Compose v5.4.0: a symlinked directory yields the symlink + # name, not its target), lowercases it, deletes every character outside + # [a-z0-9_-], then trims leading "_" and "-". Verified: "nb.test" -> "nbtest", + # "my nb" -> "mynb", "NetBird-1.0" -> "netbird-10". Networks are then named + # _. + local project + project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" + project=$(echo "$project" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g; s/^[_-]*//') + check_docker_subnet_conflicts "${project}_netbird" + return 0 +} + ############################################ # Initialization and Configuration ############################################ @@ -422,7 +595,11 @@ initialize_default_values() { BIND_LOCALHOST_ONLY="true" EXTERNAL_PROXY_NETWORK="" - # Traefik static IP within the internal bridge network + # Internal bridge network. Management and proxy trust forwarded headers + # from TRAEFIK_IP only, so all three values derive from the same /24. + # Override with NETBIRD_DOCKER_SUBNET. + DOCKER_SUBNET="172.30.0.0/24" + DOCKER_GATEWAY="172.30.0.1" TRAEFIK_IP="172.30.0.10" # NetBird Proxy configuration @@ -726,8 +903,23 @@ init_environment() { check_docker_sock_perms initialize_default_values + apply_docker_subnet_override + + # The agent-network preset pins built-in Traefik up front, so the subnet is + # already settled and a conflict can be reported before the prompts. + local subnet_checked="false" + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + configure_docker_subnet + subnet_checked="true" + fi + configure_domain configure_reverse_proxy + # Interactive runs only learn the proxy type above, and modes 1-5 never pin a + # subnet, so their check has to wait for that choice. + if [[ "$subnet_checked" != "true" ]]; then + configure_docker_subnet + fi check_jq @@ -947,8 +1139,8 @@ networks: driver: bridge ipam: config: - - subnet: 172.30.0.0/24 - gateway: 172.30.0.1 + - subnet: $DOCKER_SUBNET + gateway: $DOCKER_GATEWAY EOF return 0 } diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh index 67895fab681..232a1f6bc9e 100755 --- a/infrastructure_files/migrate.sh +++ b/infrastructure_files/migrate.sh @@ -10,6 +10,9 @@ # # Usage: # ./migrate.sh [--install-dir /path/to/netbird] [--non-interactive] +# +# Environment: +# NETBIRD_DOCKER_SUBNET /24 for the generated Docker network (default 172.30.0.0/24) set -euo pipefail @@ -64,6 +67,14 @@ TRUSTED_PEERS="" MANAGEMENT_JSON_PATH="" BACKUP_DIR="" +# Docker network for the generated Traefik compose. The Traefik container needs +# a static address so the generated config can trust it, and Traefik's IP is +# derived from the subnet, so both values stay in the same /24. Override with +# NETBIRD_DOCKER_SUBNET. +DOCKER_SUBNET="172.30.0.0/24" +DOCKER_GATEWAY="172.30.0.1" +TRAEFIK_IP="172.30.0.10" + ############################################ # Utility Functions ############################################ @@ -117,6 +128,165 @@ confirm_action() { return 0 } +############################################ +# Docker Network Subnet Override and Conflict Check +############################################ + +ip_to_int() { + local a b c d + IFS=. read -r a b c d <<< "$1" + echo $(( (10#$a << 24) + (10#$b << 16) + (10#$c << 8) + 10#$d )) +} + +# cidrs_overlap — succeeds if the networks overlap +cidrs_overlap() { + local net1="${1%/*}" len1="${1#*/}" net2="${2%/*}" len2="${2#*/}" + local min_len=$(( len1 < len2 ? len1 : len2 )) + local mask=0 + if [[ "$min_len" -gt 0 ]]; then + mask=$(( (0xFFFFFFFF << (32 - min_len)) & 0xFFFFFFFF )) + fi + [[ $(( $(ip_to_int "$net1") & mask )) -eq $(( $(ip_to_int "$net2") & mask )) ]] +} + +# valid_ipv4_slash24 — accepts a unicast IPv4 /24 like 10.123.45.0/24 +valid_ipv4_slash24() { + local octet='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])' + local re="^${octet}\.${octet}\.${octet}\.0/24$" + [[ "$1" =~ $re ]] || return 1 + # Reject non-unicast/reserved ranges: 0/8, loopback, link-local, 224+. + # 100.64/10 is rejected too: NetBird allocates overlay peer addresses from + # it by default, and a bridge there shadows the overlay without any Docker + # network overlapping, so the conflict check below would not catch it. + case "$1" in + 0.*|127.*|169.254.*|22[4-9].*|2[34][0-9].*|25[0-5].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;; + esac + return 0 +} + +# Apply NETBIRD_DOCKER_SUBNET and derive the gateway (.1) and Traefik IP (.10). +# Runs during preflight so a bad value fails before anything is touched. +# +# Unlike the getting-started scripts, the subnet has a single consumer here: the +# generated docker-compose.yml. The reverseProxy trust pins in the generated +# config.yaml are carried over verbatim from the old management.json (see +# extract_config_values), so TRAEFIK_IP is deliberately not wired into them. If +# an old config already pinned an address inside the default 172.30.0.0/24, +# overriding the subnet will not update that pin. +apply_docker_subnet_override() { + if [[ -n "${NETBIRD_DOCKER_SUBNET:-}" ]]; then + if ! valid_ipv4_slash24 "$NETBIRD_DOCKER_SUBNET"; then + log_error "NETBIRD_DOCKER_SUBNET must be a unicast IPv4 /24 network like 10.123.45.0/24 (0/8, 127/8, 169.254/16, 100.64/10, and 224+ are not allowed), got: $NETBIRD_DOCKER_SUBNET" + exit 1 + fi + DOCKER_SUBNET="$NETBIRD_DOCKER_SUBNET" + fi + local base="${DOCKER_SUBNET%.0/24}" + DOCKER_GATEWAY="${base}.1" + TRAEFIK_IP="${base}.10" + return 0 +} + +# check_docker_subnet_conflicts +# Fail before the new docker-compose.yml is written if an existing Docker +# network overlaps DOCKER_SUBNET, instead of letting "docker compose up" fail +# later. Host routes are not checked; NETBIRD_DOCKER_SUBNET covers those cases. +check_docker_subnet_conflicts() { + local expected_network="$1" + if ! command -v docker &> /dev/null; then + log_error "The Docker CLI was not found in PATH." + echo "It is required to verify that $DOCKER_SUBNET is free before the new compose file is written." + echo "The old deployment is stopped at this point; restart it with:" + echo " bash $BACKUP_DIR/rollback.sh" + exit 1 + fi + + # docker's own stderr is left visible on purpose: "is the daemon running" + # and socket permission errors are the actionable part. Only the exit status + # is handled here, because skipping the check silently would resurface later + # as a confusing "docker compose up" failure. + local ids_raw ls_status=0 + ids_raw="$(docker network ls -q)" || ls_status=$? + if [[ "$ls_status" -ne 0 ]]; then + log_error "Could not list the existing Docker networks (docker network ls exited $ls_status)." + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." + echo "Make sure the Docker daemon is running and reachable by this user, then run this script again." + echo "The old deployment is stopped at this point; restart it with:" + echo " bash $BACKUP_DIR/rollback.sh" + exit 1 + fi + + # Collect the IDs in an array so they reach docker as separate arguments + local network_ids=() id + while IFS= read -r id; do + if [[ -n "$id" ]]; then + network_ids+=("$id") + fi + done <<< "$ids_raw" + + # No Docker networks at all: nothing can overlap, so there is nothing to check + [[ "${#network_ids[@]}" -gt 0 ]] || return 0 + + local inspect_output inspect_status=0 + inspect_output="$(docker network inspect --format '{{.Name}}|{{range .IPAM.Config}}{{.Subnet}} {{end}}' "${network_ids[@]}")" || inspect_status=$? + if [[ "$inspect_status" -ne 0 ]]; then + log_error "Could not inspect the existing Docker networks (docker network inspect exited $inspect_status)." + echo "Without it this script cannot verify that $DOCKER_SUBNET is free." + echo "If a Docker network was removed while this script was running, run the script again." + echo "The old deployment is stopped at this point; restart it with:" + echo " bash $BACKUP_DIR/rollback.sh" + exit 1 + fi + + local name subnets subnet + while IFS='|' read -r name subnets; do + for subnet in $subnets; do + [[ "$subnet" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || continue + if [[ "$name" == "$expected_network" ]]; then + # Our own leftover network: compose reuses it as-is, so its subnet + # must match the one we render + if [[ "$subnet" != "$DOCKER_SUBNET" ]]; then + log_error "The Docker network '$name', left over from an earlier run, uses $subnet instead of $DOCKER_SUBNET." + echo "docker compose would reuse it as-is, and the generated configuration would not match it." + echo "Remove it and run this script again:" + echo " docker network rm $name" + exit 1 + fi + elif cidrs_overlap "$DOCKER_SUBNET" "$subnet"; then + log_error "The existing Docker network '$name' ($subnet) overlaps $DOCKER_SUBNET, the subnet NetBird would use." + echo "That network is not managed by this script and is left untouched." + echo "If it belongs to the old NetBird deployment and is no longer in use, remove it:" + echo " docker network rm $name" + echo "Otherwise pick a free /24 for NetBird instead and run this script again:" + echo " NETBIRD_DOCKER_SUBNET=10.123.45.0/24 ./migrate.sh" + exit 1 + fi + done + done <<< "$inspect_output" + return 0 +} + +# Only reached on the automatic (embedded Caddy) path, which is the only one +# that generates a compose file pinning a subnet; the exposed-ports compose for +# custom proxies lets Docker pick. +configure_docker_subnet() { + # Skip our own network (_netbird) in the conflict check. start_new_services + # runs "cd $INSTALL_DIR && compose up", a logical cd, and compose derives the + # project name from the basename of that logical path -- so resolve it the same + # way with a plain "pwd" (a relative --install-dir still yields an absolute + # path, and a symlinked install dir keeps the symlink name, which is what + # compose sees). "pwd -P" here would resolve the symlink target and no longer + # match. Compose then lowercases, deletes every character outside [a-z0-9_-], + # and trims leading "_" and "-"; verified against Compose v5.4.0 that + # "nb.test" -> "nbtest" and "my nb" -> "mynb". + local project + project="${COMPOSE_PROJECT_NAME:-$(basename "$(cd -- "$INSTALL_DIR" && pwd)")}" + project=$(echo "$project" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g; s/^[_-]*//') + check_docker_subnet_conflicts "${project}_netbird" + return 0 +} + ############################################ # Phase 0: Preflight & Detection ############################################ @@ -577,6 +747,7 @@ print_detection_summary() { echo " Migration mode: AUTOMATIC" echo " A Traefik-based docker-compose.yml will be generated and services" echo " will be stopped and restarted automatically." + echo " Docker subnet: $DOCKER_SUBNET (Traefik at $TRAEFIK_IP)" else echo " Migration mode: MANUAL" echo " New config files will be generated. You will need to stop old" @@ -843,7 +1014,7 @@ services: restart: unless-stopped networks: netbird: - ipv4_address: 172.30.0.10 + ipv4_address: ${TRAEFIK_IP} command: # Logging - "--log.level=INFO" @@ -952,8 +1123,8 @@ networks: driver: bridge ipam: config: - - subnet: 172.30.0.0/24 - gateway: 172.30.0.1 + - subnet: ${DOCKER_SUBNET} + gateway: ${DOCKER_GATEWAY} EOF log_success "Generated docker-compose.yml" @@ -1226,6 +1397,10 @@ main() { echo " --install-dir DIR Path to existing NetBird installation" echo " --non-interactive Skip confirmation prompts (for automation)" echo " -h, --help Show this help message" + echo "" + echo "Environment:" + echo " NETBIRD_DOCKER_SUBNET /24 for the generated Docker network" + echo " (default $DOCKER_SUBNET; Traefik takes .10)" exit 0 ;; *) @@ -1240,6 +1415,7 @@ main() { # Phase 0: Preflight & Detection check_dependencies + apply_docker_subnet_override detect_install_dir validate_old_setup check_already_migrated @@ -1261,6 +1437,10 @@ main() { # Stop old containers BEFORE overwriting docker-compose.yml stop_old_services + # "compose down" above released the old deployment's networks, so anything + # still overlapping now is a network this script must not touch + configure_docker_subnet + # Phase 2 + 3: Generate new configuration files generate_config_yaml generate_dashboard_env