#!/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. # Add features to an existing install with --enable-proxy or --enable-traffic-events. SED_STRIP_PADDING='s/=//g' NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" STACK_FILES=(.env docker-compose.yml config.yaml) # Host directory of a custom TLS certificate mounted at /certs, see # https://docs.netbird.io/selfhosted/enterprise/getting-started#appendix-using-a-custom-tls-certificate CUSTOM_TLS_CERTS="" PROXY_TOKEN_ID="" # Static IP for Traefik inside the compose bridge network. The management # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" LICENSE_VERDICT="unknown" LICENSE_LOG_LINES="" check_docker_compose() { if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr exit 1 fi if docker compose version &> /dev/null; then echo "docker compose" return fi if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then echo "docker-compose" return fi echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/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 } die() { echo "$1" > /dev/stderr exit 1 } # env_get KEY [DEFAULT] prints KEY's value from .env, or DEFAULT if unset. env_get() { local value value=$(sed -n "s/^$1=//p" .env | tail -n 1) echo "${value:-$2}" } # merge_env upserts the KEY=VALUE lines from stdin into .env, in place. merge_env() { local merged merged=$(awk -F= 'NR == FNR { v[$1] = $0; o[++n] = $1; next } $1 in v { print v[$1]; delete v[$1]; next } { print } END { for (i = 1; i <= n; i++) if (o[i] in v) print v[o[i]] }' - .env) printf '%s\n' "$merged" > .env } rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" } rand_b64_key() { openssl rand -base64 32 } 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 } read_crowdsec_option() { echo "" echo "CrowdSec:" echo " Checks client IPs against a community threat intelligence database and" echo " blocks known malicious sources before they reach services exposed through" echo " the proxy. Adds a CrowdSec container to the stack." NETBIRD_CROWDSEC=$(read_yes_no "Enable CrowdSec" "n") } # 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 } wait_for_license_verdict() { local counter=0 local logs="" echo -n "Waiting for the server to validate the license" while [[ $counter -lt 60 ]]; do logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all netbird-server 2>/dev/null || true) if grep -qi "license invalidated" <<< "$logs"; then echo " rejected" LICENSE_VERDICT="rejected" LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) return 0 fi if grep -qi "license validated" <<< "$logs"; then echo " ok" LICENSE_VERDICT="ok" return 0 fi echo -n " ." sleep 2 counter=$((counter + 1)) done echo " no verdict in 120s" LICENSE_VERDICT="unknown" LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) return 0 } report_license_verdict() { if [[ "$LICENSE_VERDICT" == "ok" ]]; then return 0 fi if [[ "$LICENSE_VERDICT" == "unknown" ]]; then echo "" echo " ⚠ The server logged no license verdict within 120s." if [[ -n "$LICENSE_LOG_LINES" ]]; then echo " It was still reporting validation errors:" while IFS= read -r line; do [[ -n "$line" ]] && echo " $line" done <<< "$LICENSE_LOG_LINES" fi echo "" echo " Check the verdict with:" echo "" echo " $DOCKER_COMPOSE_COMMAND logs netbird-server | grep -i license" return 0 fi local unreachable="false" if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then unreachable="true" fi echo "" if [[ "$unreachable" == "true" ]]; then echo " ⚠ The server could not validate the license:" else echo " ⚠ The server rejected the license key:" fi while IFS= read -r line; do [[ -n "$line" ]] && echo " $line" done <<< "$LICENSE_LOG_LINES" echo "" echo " The stack is up, and only the license check did not pass." echo "" if [[ "$unreachable" == "true" ]]; then echo " The license server could not be reached, so the key itself was" echo " never checked. Confirm this host has outbound access to the" echo " license server, then restart:" else echo " Check the reason the server gave above, verify that" echo " NETBIRD_LICENSE_KEY in .env matches the key you were issued," echo " then restart:" fi echo "" echo " $DOCKER_COMPOSE_COMMAND up -d" return 0 } # up_all_but_proxy skips the proxy, which needs a token from the running server. up_all_but_proxy() { local services services=$($DOCKER_COMPOSE_COMMAND config --services | grep -vx proxy) # shellcheck disable=SC2086 $DOCKER_COMPOSE_COMMAND up -d $services } wait_crowdsec() { $DOCKER_COMPOSE_COMMAND up -d crowdsec || return 1 for _ in {1..60}; do $DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli lapi status &> /dev/null && return 0 sleep 2 done return 1 } admin_token() { $DOCKER_COMPOSE_COMMAND run --rm --no-deps -T netbird-server admin token "$@" --config /etc/netbird/config.yaml } # revoke_proxy_token revokes the token this run minted if the proxy never started. # On failure the ID is kept, so rollback retries it. revoke_proxy_token() { [[ -n "$PROXY_TOKEN_ID" ]] || return 0 if admin_token revoke "$PROXY_TOKEN_ID" > /dev/null; then PROXY_TOKEN_ID="" return 0 fi echo "Could not revoke the unused proxy token ${PROXY_TOKEN_ID}. Revoke it with:" > /dev/stderr echo " $DOCKER_COMPOSE_COMMAND run --rm netbird-server admin token revoke ${PROXY_TOKEN_ID} --config /etc/netbird/config.yaml" > /dev/stderr } # start_proxy mints the proxy token and CrowdSec bouncer key, then starts the proxy. start_proxy() { local out token key echo "Creating the proxy access token ..." out=$(admin_token create --name default-proxy) || true token=$(awk '/^Token:/ {print $2}' <<< "$out") PROXY_TOKEN_ID=$(awk '/^Token ID:/ {print $3}' <<< "$out") [[ -n "$token" ]] || die "Could not create the proxy access token. Check the netbird-server logs, then re-run with --enable-proxy." if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then echo "Registering the CrowdSec bouncer ..." if wait_crowdsec; then # "add" fails if an earlier attempt already registered the bouncer. $DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers delete netbird-proxy &> /dev/null || true key=$($DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers add netbird-proxy -o raw) || true fi if [[ -z "$key" ]]; then revoke_proxy_token die "Could not register the CrowdSec bouncer. Check the crowdsec logs, then re-run with --enable-proxy." fi fi # A stored token marks the proxy as set up, so it is cleared again on failure. { echo "NETBIRD_PROXY_TOKEN=${token}" if [[ -n "$key" ]]; then echo "NETBIRD_CROWDSEC_BOUNCER_KEY=${key}"; fi } | merge_env if ! $DOCKER_COMPOSE_COMMAND up -d proxy; then revoke_proxy_token echo "NETBIRD_PROXY_TOKEN=" | merge_env die "Could not start the proxy. Check the proxy logs, then re-run with --enable-proxy." fi PROXY_TOKEN_ID="" } print_proxy_notes() { echo "" echo "NetBird Proxy:" echo " Every domain other than ${NETBIRD_DOMAIN} is passed through to the proxy," echo " which issues its own TLS certificates. Point proxy domains at this host:" echo "" echo " *.${NETBIRD_DOMAIN} CNAME ${NETBIRD_DOMAIN}" echo "" echo " Open 51820/udp (optional) for peer-to-peer proxy connections." if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then echo " CrowdSec is running. Enable it per service in the dashboard under Access Control." fi } init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then echo "Generated files already exist in $(pwd)." echo "To add the proxy or traffic events to this installation, re-run with" echo "--enable-proxy or --enable-traffic-events." echo "" 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 -rf .env docker-compose.yml config.yaml traefik" 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 "" echo "NetBird Proxy:" echo " Exposes selected resources from your NetBird network to the internet." echo " You choose which resources are exposed from the dashboard." NETBIRD_PROXY=$(read_yes_no "Enable the NetBird Proxy" "n") NETBIRD_CROWDSEC="no" if [[ "$NETBIRD_PROXY" == "yes" ]]; then read_crowdsec_option fi 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_SESSION_COOKIE_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 " Proxy: ${NETBIRD_PROXY}" echo " CrowdSec: ${NETBIRD_CROWDSEC}" echo " Domain: ${NETBIRD_DOMAIN}" echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" echo "" echo "Rendering files into $(pwd) ..." install -m 600 /dev/null .env render_env >> .env render_docker_compose > docker-compose.yml mkdir -p traefik if [[ "$NETBIRD_PROXY" == "yes" ]]; then render_traefik_proxy > traefik/proxy.yaml 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 ..." up_all_but_proxy echo "" wait_for_license_verdict if [[ "$NETBIRD_PROXY" == "yes" ]]; then echo "" start_proxy fi 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" if [[ "$NETBIRD_PROXY" == "yes" ]]; then print_proxy_notes fi echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" report_license_verdict if [[ "$LICENSE_VERDICT" == "rejected" ]]; then exit 1 fi } # service_block NAME prints a service's definition from the compose file on stdin. service_block() { local name="$1" awk -v s=" ${name}:" '$0 == s { p = 1; print; next } p && (/^[^ ]/ || /^ [^ ]/) { exit } p' } # enable_features adds the proxy and/or traffic events to the install in the # current directory, restoring the backed-up files if any step fails. enable_features() { local want_proxy="$1" want_flow="$2" f compose DOCKER_COMPOSE_COMMAND=$(check_docker_compose) for f in "${STACK_FILES[@]}"; do [[ -f "$f" ]] || die "$f not found in $(pwd). Run this from an existing installation directory." done grep -q '^# Generated by getting-started-enterprise.sh' .env || die ".env was not generated by getting-started-enterprise.sh." # Installs from before the move to Traefik run Caddy and can't be re-rendered. [[ -n "$(env_get NETBIRD_TRAEFIK_IP)" ]] || die "This installation predates the Traefik layout and can't be updated in place." NETBIRD_DOMAIN=$(env_get NETBIRD_DOMAIN) NETBIRD_LICENSE_SERVER_BASE_URL=$(env_get NETBIRD_LICENSE_SERVER_BASE_URL) NETBIRD_TRAFFIC_FLOW=$(env_get NETBIRD_TRAFFIC_FLOW_ENABLED no) NETBIRD_PROXY=$(env_get NETBIRD_PROXY_ENABLED no) NETBIRD_CROWDSEC=$(env_get NETBIRD_CROWDSEC_ENABLED no) # A custom certificate counts only once Traefik mounts it and ACME is already gone, # so the re-render never removes a working Let's Encrypt setup. local traefik_block traefik_block=$(service_block traefik < docker-compose.yml) if ! grep -q certificatesresolvers <<< "$traefik_block"; then CUSTOM_TLS_CERTS=$(awk '/:\/certs:ro$/ { sub(/^ *- /, ""); sub(/:\/certs:ro$/, ""); print; exit }' <<< "$traefik_block") fi if [[ "$want_flow" == "yes" && "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then echo "Traffic events are already enabled." want_flow="no" fi # No token means an earlier proxy setup failed, so let it run again. if [[ "$want_proxy" == "yes" && -n "$(env_get NETBIRD_PROXY_TOKEN)" ]]; then echo "The NetBird Proxy is already enabled." want_proxy="no" fi if [[ "$want_flow" == "no" && "$want_proxy" == "no" ]]; then exit 0 fi if [[ "$want_flow" == "yes" ]]; then NETBIRD_TRAFFIC_FLOW="yes" fi # A retry keeps the CrowdSec choice made at install time. if [[ "$want_proxy" == "yes" && "$NETBIRD_PROXY" != "yes" ]]; then read_crowdsec_option fi if [[ "$want_proxy" == "yes" ]]; then NETBIRD_PROXY="yes" fi compose=$(render_docker_compose) echo "" echo "Changes to docker-compose.yml:" printf '%s\n' "$compose" | diff -u docker-compose.yml - || true # Lines the new file drops are most likely local edits, so don't default to applying. local lost s restarts="" apply="y" lost=$(printf '%s\n' "$compose" | awk 'NR == FNR { keep[$0]; next } !($0 in keep)' - docker-compose.yml) if [[ -n "$lost" ]]; then echo "" echo "These lines are not in the new docker-compose.yml and will be lost:" printf '%s\n' "$lost" apply="n" fi for s in $($DOCKER_COMPOSE_COMMAND config --services); do if [[ "$(service_block "$s" < docker-compose.yml)" != "$(printf '%s\n' "$compose" | service_block "$s")" ]] \ || [[ "$s" == "netbird-server" && "$want_flow" == "yes" ]]; then restarts+=" $s" fi done echo "" if [[ -n "$restarts" ]]; then echo "These services will restart:${restarts}" fi if [[ "$(read_yes_no "Apply these changes?" "$apply")" != "yes" ]]; then echo "Aborted." exit 0 fi BACKUP_SUFFIX=".bak.$(date -u +%Y%m%d%H%M%S)" for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do if [[ -f "$f" ]]; then cp -p "$f" "$f$BACKUP_SUFFIX"; fi done trap rollback EXIT printf '%s\n' "$compose" > docker-compose.yml { echo "NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}" echo "NETBIRD_PROXY_ENABLED=${NETBIRD_PROXY}" echo "NETBIRD_CROWDSEC_ENABLED=${NETBIRD_CROWDSEC}" if [[ "$want_flow" == "yes" ]]; then render_env_flow; fi if [[ "$want_proxy" == "yes" ]]; then render_env_proxy; fi } | merge_env if [[ "$want_flow" == "yes" ]] && ! grep -q '^ trafficFlow:' config.yaml; then render_config_flow >> config.yaml fi mkdir -p traefik if [[ "$want_proxy" == "yes" ]]; then render_traefik_proxy > traefik/proxy.yaml fi up_all_but_proxy if [[ "$want_flow" == "yes" ]]; then # Compose does not notice changes to the bind-mounted config.yaml. $DOCKER_COMPOSE_COMMAND restart netbird-server fi if [[ "$want_proxy" == "yes" ]]; then start_proxy fi trap - EXIT echo "" echo "Done. The previous files are kept with the ${BACKUP_SUFFIX} suffix." if [[ "$want_flow" == "yes" ]]; then echo "" echo "Traffic events still have to be turned on from the dashboard settings." echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" fi if [[ "$want_proxy" == "yes" ]]; then print_proxy_notes fi } rollback() { local f echo "" > /dev/stderr echo "Enabling failed. Restoring the previous configuration ..." > /dev/stderr revoke_proxy_token # Files without a backup were created by this run. for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do if [[ -f "$f$BACKUP_SUFFIX" ]]; then cp -p "$f$BACKUP_SUFFIX" "$f"; else rm -f "$f"; fi done $DOCKER_COMPOSE_COMMAND up -d --remove-orphans $DOCKER_COMPOSE_COMMAND restart netbird-server } # ------------------------------------------------------------------ # Renderers # ------------------------------------------------------------------ render_env() { cat < /dev/stderr; exit 1 ;; esac shift done if [[ "$enable_proxy" == "no" && "$enable_flow" == "no" ]]; then init_environment else enable_features "$enable_proxy" "$enable_flow" fi } main "$@"