diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 87b0d65ee..7b3f70011 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,90 @@ 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+ + case "$1" in + 0.*|127.*|169.254.*|22[4-9].*|2[34][0-9].*|25[0-5].*) 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, 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" + command -v docker &> /dev/null || return 0 + + 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 < <(docker network inspect --format '{{.Name}}|{{range .IPAM.Config}}{{.Subnet}} {{end}}' $(docker network ls -q 2>/dev/null) 2>/dev/null || true) + return 0 +} + check_nb_domain() { local domain="$1" if [[ -z "$domain" ]]; then @@ -224,6 +311,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)." @@ -272,6 +362,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 @@ -333,7 +424,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} @@ -416,6 +512,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" @@ -659,8 +758,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 0206c269a..d366353d6 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -108,6 +108,14 @@ 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 + local re='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' + if [[ "$DOMAIN" != "use-ip" ]] && [[ ! "$DOMAIN" =~ $re ]]; then + echo "The NETBIRD_DOMAIN may only contain letters, digits, dots, and hyphens." > /dev/stderr + return 1 + fi return 0 } @@ -337,6 +345,103 @@ 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+ + case "$1" in + 0.*|127.*|169.254.*|22[4-9].*|2[34][0-9].*|25[0-5].*) 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, 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" + command -v docker &> /dev/null || return 0 + + 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 < <(docker network inspect --format '{{.Name}}|{{range .IPAM.Config}}{{.Subnet}} {{end}}' $(docker network ls -q 2>/dev/null) 2>/dev/null || true) + 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; + # normalization matches compose-go's NormalizeProjectName + 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 ############################################ @@ -368,7 +473,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 @@ -663,8 +772,10 @@ init_environment() { check_docker_sock_perms initialize_default_values + apply_docker_subnet_override configure_domain configure_reverse_proxy + configure_docker_subnet check_jq @@ -686,6 +797,7 @@ render_docker_compose_traefik_builtin() { local crowdsec_volumes="" local traefik_file_provider="" local traefik_dynamic_volume="" + if [[ "$ENABLE_PROXY" == "true" ]]; then traefik_file_provider=' - "--providers.file.filename=/etc/traefik/dynamic.yaml"' traefik_dynamic_volume=" - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro" @@ -884,8 +996,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 }