Compare commits

...

9 Commits

Author SHA1 Message Date
Brandon Hopkins
123321d58c Merge branch 'main' into fix/quickstart-subnet-and-domain-alias 2026-08-12 11:53:03 -07:00
Brandon Hopkins
39905f1212 normalization fix, migrate bug pwd -P 2026-08-12 11:16:43 -07:00
Brandon Hopkins
96e9598fcc Fix compose project-normalization 2026-08-12 10:34:11 -07:00
Brandon Hopkins
34a24d23b3 Merge branch 'main' into fix/quickstart-subnet-and-domain-alias 2026-08-12 10:16:39 -07:00
Brandon Hopkins
6a6e7bf468 migrate.sh subnet override plus conflict check 2026-08-11 02:09:26 -07:00
Brandon Hopkins
d1f51b38eb Subnet check timing 2026-08-11 01:20:44 -07:00
Brandon Hopkins
01bc7234b1 Hardened Docker network error handling 2026-08-11 01:16:21 -07:00
Brandon Hopkins
384b58df6d Merge branch 'main' into fix/quickstart-subnet-and-domain-alias 2026-08-10 22:25:51 -07:00
Brandon Hopkins
2f18a1bd7f Subnet docker conflict check; enterprice domain alias. 2026-08-05 15:38:25 -07:00
3 changed files with 491 additions and 9 deletions

View File

@@ -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,127 @@ 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 <cidr> <cidr> — 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 <cidr> — 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 <compose network name>
# 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"
command -v docker &> /dev/null || return 0
# 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
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
exit 1
fi
done
done <<< "$inspect_output"
return 0
}
check_nb_domain() {
local domain="$1"
if [[ -z "$domain" ]]; then
@@ -224,6 +348,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 +400,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 +462,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 +550,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 +796,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
}

View File

@@ -108,6 +108,20 @@ check_nb_domain() {
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
return 1
fi
# Letters, digits, dots, and hyphens only; the domain is embedded in
# generated YAML and env files. This is not FQDN validation: "use-ip" and
# bare IP addresses are valid inputs here and both satisfy the pattern.
local re='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'
if [[ ! "$DOMAIN" =~ $re ]] || [[ "$DOMAIN" == *..* ]]; then
echo "The NETBIRD_DOMAIN may only contain letters, digits, dots, and hyphens, and cannot begin or end with a dot or hyphen." > /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
}
@@ -337,6 +351,145 @@ 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 <cidr> <cidr> — 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 <cidr> — 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 <compose network name>
# 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"
command -v docker &> /dev/null || return 0
# 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
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
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 (<project>_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
# <project>_<key>.
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
############################################
@@ -369,7 +522,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
@@ -665,8 +822,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
@@ -886,8 +1058,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
}

View File

@@ -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,159 @@ 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 <cidr> <cidr> — 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 <cidr> — 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 <compose network name>
# 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"
command -v docker &> /dev/null || return 0
# 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 (<project>_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 +741,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 +1008,7 @@ services:
restart: unless-stopped
networks:
netbird:
ipv4_address: 172.30.0.10
ipv4_address: ${TRAEFIK_IP}
command:
# Logging
- "--log.level=INFO"
@@ -952,8 +1117,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 +1391,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 +1409,7 @@ main() {
# Phase 0: Preflight & Detection
check_dependencies
apply_docker_subnet_override
detect_install_dir
validate_old_setup
check_already_migrated
@@ -1261,6 +1431,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