#!/bin/bash set -e set -o pipefail # NetBird Enterprise — Getting Started # Single-node bootstrap for a self-hosted NetBird Enterprise stack with the # embedded identity provider. Owner is created via first-login flow. 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, 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() { if command -v docker-compose &> /dev/null; then echo "docker-compose" return fi if docker compose --help &> /dev/null; then echo "docker compose" return fi echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr exit 1 } check_openssl() { if ! command -v openssl &> /dev/null; then echo "openssl is not installed or not in PATH." > /dev/stderr exit 1 fi } rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" } 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 echo "The domain cannot be empty." > /dev/stderr return 1 fi if [[ "$domain" == "netbird.example.com" ]]; then echo "The domain cannot be netbird.example.com" > /dev/stderr return 1 fi if [[ "$domain" =~ ^[0-9.]+$ ]]; then echo "An IP address is not allowed. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr return 1 fi if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then echo "The value '$domain' is not a valid FQDN. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr return 1 fi return 0 } check_domain_resolves() { local domain="$1" if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi return 1 } read_nb_domain() { local value="" echo -n "Enter the FQDN for NetBird (must resolve via DNS, e.g. netbird.my-domain.com): " > /dev/stderr read -r value < /dev/tty if ! check_nb_domain "$value"; then read_nb_domain return fi if ! check_domain_resolves "$value"; then echo "" > /dev/stderr echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr local confirm="" echo -n "Continue anyway? [y/N]: " > /dev/stderr read -r confirm < /dev/tty if [[ ! "$confirm" =~ ^[Yy]$ ]]; then read_nb_domain return fi fi echo "$value" } read_letsencrypt_email() { if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then echo "$NETBIRD_LETSENCRYPT_EMAIL" return fi local value="" echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr echo -n "Email address: " > /dev/stderr read -r value < /dev/tty if [[ -z "$value" ]]; then echo "Email is required for Let's Encrypt." > /dev/stderr read_letsencrypt_email return fi echo "$value" } read_required() { local prompt="$1" local value="" while [[ -z "$value" ]]; do echo -n "$prompt: " > /dev/stderr read -r value < /dev/tty if [[ -z "$value" ]]; then echo "Value cannot be empty." > /dev/stderr fi done echo "$value" } read_secret() { local prompt="$1" local value="" while [[ -z "$value" ]]; do echo -n "$prompt: " > /dev/stderr read -rs value < /dev/tty echo "" > /dev/stderr if [[ -z "$value" ]]; then echo "Value cannot be empty." > /dev/stderr fi done echo "$value" } # read_yes_no "" [] read_yes_no() { local prompt="$1" local default="${2:-n}" local hint if [[ "$default" == "y" ]]; then hint="[Y/n]" else hint="[y/N]" fi echo -n "${prompt} ${hint}: " > /dev/stderr local ans="" read -r ans < /dev/tty if [[ -z "$ans" ]]; then ans="$default" fi case "$ans" in [Yy] | [Yy][Ee][Ss]) echo "yes" ;; *) echo "no" ;; esac } # Gate the install on explicit acceptance of the NetBird On-Premise EULA. require_eula_acceptance() { cat > /dev/stderr < /dev/stderr return 0 fi local ans="" echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr read -r ans < /dev/tty if [[ "$ans" != "accept" ]]; then echo "" > /dev/stderr echo "EULA not accepted. Aborting installation." > /dev/stderr exit 1 fi echo "" > /dev/stderr } wait_postgres() { set +e echo -n "Waiting for postgres to become ready" local counter=1 while true; do if $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" &> /dev/null; then break fi if [[ $counter -eq 60 ]]; then echo "" echo "Postgres is taking too long. Recent logs:" $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres exit 1 fi echo -n " ." sleep 2 counter=$((counter + 1)) done echo " done" set -e } 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)." echo "If you want to reinitialize the environment, please remove them first:" echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" echo " rm -f .env docker-compose.yml config.yaml" echo "Be aware this will remove all data from the database." exit 1 fi require_eula_acceptance NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) echo "NetBird Enterprise bootstrap" echo "" echo "Traffic flow:" echo " Enables traffic events logging on the management server." echo " When enabled, the NetBird stack also runs NATS along with two" echo " additional containers: netbird-receiver (the traffic log receiver" echo " service) and netbird-enricher (the traffic log enricher service)." echo " It still has to be turned on from the dashboard settings afterwards." echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n") echo "" NETBIRD_DOMAIN=$(read_nb_domain) echo "" NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email) echo "" NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") POSTGRES_USER="netbird" POSTGRES_DB="netbird" POSTGRES_PASSWORD=$(rand_secret) NETBIRD_ENCRYPTION_KEY=$(rand_b64_key) NETBIRD_RELAY_AUTH_SECRET=$(rand_secret) POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC" NETBIRD_RELAY_ENDPOINT="rels://${NETBIRD_DOMAIN}:443" echo "" echo "Selected:" 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 render_env >> .env render_docker_compose > docker-compose.yml if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak fi install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml echo "" echo "Pulling images ..." $DOCKER_COMPOSE_COMMAND pull echo "" echo "Starting postgres ..." $DOCKER_COMPOSE_COMMAND up -d postgres sleep 2 wait_postgres echo "" echo "Starting remaining services ..." $DOCKER_COMPOSE_COMMAND up -d echo "" echo "Done." echo "" echo "Dashboard: https://${NETBIRD_DOMAIN}" echo "" echo "Open the dashboard in a browser to complete the first-login owner setup." echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" } # ------------------------------------------------------------------ # Renderers # ------------------------------------------------------------------ render_env() { cat <