Compare commits

...

12 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
Jack Carter
58c09ead21 [management] Document mutual exclusivity of policy rule ports and port_ranges (#7158) 2026-08-12 20:39:06 +02: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
Brad Ison
c5503fdc7f [misc] Build release branches, and don't mark releases latest before signing (#7171)
Prepares the repository for the release-branch process agreed internally:
one long-lived release-0.N branch per minor, with fixes backported by
cherry-pick and patch releases tagged from the branch.

Pushes to release-* branches now run the Release workflow and publish
immutable sha-* container images, the way pushes to main already do, so
a release branch can be tested before it is tagged. Release branches
never publish the floating "main" image tag. The push-to-main CI
workflows (Go tests on all platforms, frontend UI, install script,
mobile/wasm validation, infrastructure files, license check) also run
on release-* pushes; pull request triggers were already unfiltered, so
backport PRs were covered — this closes the post-merge gap.

Releases are no longer marked latest before signing: make_latest is
now false in all four goreleaser configs, so a release stays published
but not latest until the signing pipeline uploads the signed Windows
and macOS artifacts and marks it latest itself. Previously the release
became GitHub's "Latest release" at publish time, and the download
endpoints that resolve through the latest-release API could serve a
release whose signed installers did not exist yet. prerelease: auto
additionally labels rc tags as prereleases, so a release candidate can
never take the latest slot.

The trigger_sync_tag job is removed: it dispatched a downstream
image build on every v* tag (release candidates included), which would
race the deliberate release-branch build on every release. The android
and ios submodule bumps are unchanged.

Also sets perennial-regex = "^release-" so git-town never syncs or
ships a release branch into main.
2026-08-12 18:04:36 +02:00
Zoltan Papp
6b69f5c05d [client] Remove installer registry handlers for autostart Run keys (#7183)
The NSIS installer deleted HKLM/HKCU CurrentVersion\Run values it never
writes, which matches common AV heuristics for unwanted Run-key
manipulation and is suspected to contribute to Windows Defender and
third-party antivirus false positives on the installer.

Drop all autostart registry deletions from both the install and
uninstall sections so the installer only touches keys it creates
itself. Cleanup of the legacy machine-wide entry written by old
installers is left to documentation.

Extends the approach of the closed PR #6735, which only removed the
per-user deletion on uninstall.
2026-08-12 17:35:01 +02: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
23 changed files with 558 additions and 56 deletions

View File

@@ -3,7 +3,7 @@
[branches]
main = "main"
perennials = []
perennial-regex = ""
perennial-regex = "^release-"
[create]
new-branch-type = "feature"

View File

@@ -2,7 +2,7 @@ name: Check License Dependencies
on:
push:
branches: [main]
branches: [main, "release-*"]
paths:
- "go.mod"
- "go.sum"

View File

@@ -10,6 +10,7 @@ on:
push:
branches:
- main
- "release-*"
paths:
- "client/ui/frontend/**"
- "client/ui/i18n/**"

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
env:

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
paths:
- "release_files/install.sh"

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:

View File

@@ -6,6 +6,7 @@ on:
- "v*"
branches:
- main
- "release-*"
pull_request:
env:
@@ -254,15 +255,23 @@ jobs:
id: tag_and_push_images
if: |
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-')))
run: |
set -euo pipefail
# $GITHUB_REF / $GITHUB_EVENT_NAME are read from the runner
# environment rather than substituted into this script with the
# workflow expression syntax: branch names may legally contain
# $(…), and interpolating github.ref would execute it.
resolve_tags() {
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
echo "pr-${{ github.event.pull_request.number }}"
else
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
echo "main sha-$(git rev-parse --short HEAD)"
else
# Release branches get an immutable sha-* tag only — the floating
# "main" tag must never move from a release branch.
echo "sha-$(git rev-parse --short HEAD)"
fi
}

View File

@@ -9,21 +9,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true
# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short
# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
# The receiving bump-netbird workflows expect the short tag form
# (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
jobs:
trigger_sync_tag:
runs-on: ubuntu-latest
steps:
- name: Trigger release tag sync
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-tag.yml
ref: main
repo: ${{ secrets.UPSTREAM_REPO }}
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'
trigger_android_bump:
runs-on: ubuntu-latest
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
paths:
- "infrastructure_files/**"

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:

View File

@@ -468,6 +468,13 @@ checksum:
- glob: ./infrastructure_files/migrate-to-enterprise.sh
release:
# The signing pipeline (netbirdio/sign-pipelines, dispatched by
# trigger_signer) marks the release latest once the Windows and macOS
# artifacts are signed. Without this override goreleaser marks it latest
# at publish time, while those artifacts are still unsigned.
make_latest: false
# Mark x.y.z-rc.* and other prerelease tags as prereleases on GitHub.
prerelease: auto
extra_files:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh

View File

@@ -144,3 +144,11 @@ uploads:
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT
release:
# Uploads into the release created by the main .goreleaser.yaml run.
# make_latest stays false everywhere: the signing pipeline
# (netbirdio/sign-pipelines) marks the release latest after the Windows
# and macOS artifacts are signed.
make_latest: false
prerelease: auto

View File

@@ -43,3 +43,11 @@ checksum:
name_template: "{{ .ProjectName }}_darwin_checksums.txt"
changelog:
disable: true
release:
# Uploads into the release created by the main .goreleaser.yaml run.
# make_latest stays false everywhere: the signing pipeline
# (netbirdio/sign-pipelines) marks the release latest after the Windows
# and macOS artifacts are signed.
make_latest: false
prerelease: auto

View File

@@ -134,3 +134,11 @@ uploads:
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT
release:
# Uploads into the release created by the main .goreleaser.yaml run.
# make_latest stays false everywhere: the signing pipeline
# (netbirdio/sign-pipelines) marks the release latest after the Windows
# and macOS artifacts are signed.
make_latest: false
prerelease: auto

View File

@@ -22,8 +22,6 @@
!define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}"
!define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}"
!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run"
!define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird"
Unicode True
@@ -228,13 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
; not the installer. Drop the machine-wide entry older installers wrote so the
; toggle is the single source of truth. HKCU is left untouched -- it may hold
; the user's own toggle state, which must survive upgrades.
DetailPrint "Removing installer-managed autostart registry entry if present..."
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
EnVar::SetHKLM
EnVar::AddValueEx "path" "$INSTDIR"
@@ -299,15 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
DetailPrint "Terminating Netbird UI process..."
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
; Remove autostart registry entries
DetailPrint "Removing autostart registry entries if they exist..."
; Legacy machine-wide entry written by older installers.
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
; app-name slug). Uninstall removes the app, so drop it too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
${If} $DeleteDataEnabled == "1"

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

View File

@@ -1433,13 +1433,14 @@ components:
enum: [ "all", "tcp", "udp", "icmp", "netbird-ssh" ]
example: "tcp"
ports:
description: Policy rule affected ports
description: Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
x-omit-from-example: true
type: array
items:
type: string
example: "80"
port_ranges:
description: Policy rule affected ports ranges list
description: Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
type: array
items:
$ref: '#/components/schemas/RulePortRange'
@@ -1459,7 +1460,7 @@ components:
- action
RulePortRange:
description: Policy rule affected ports range
description: Policy rule affected ports range. A range with identical start and end values represents a single port.
type: object
properties:
start:

View File

@@ -4468,10 +4468,10 @@ type PolicyRule struct {
// Name Policy rule name identifier
Name string `json:"name"`
// PortRanges Policy rule affected ports ranges list
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
// Ports Policy rule affected ports
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
Ports *[]string `json:"ports,omitempty"`
// Protocol Policy rule type of the traffic
@@ -4508,10 +4508,10 @@ type PolicyRuleMinimum struct {
// Name Policy rule name identifier
Name string `json:"name"`
// PortRanges Policy rule affected ports ranges list
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
// Ports Policy rule affected ports
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
Ports *[]string `json:"ports,omitempty"`
// Protocol Policy rule type of the traffic
@@ -4551,10 +4551,10 @@ type PolicyRuleUpdate struct {
// Name Policy rule name identifier
Name string `json:"name"`
// PortRanges Policy rule affected ports ranges list
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
// Ports Policy rule affected ports
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
Ports *[]string `json:"ports,omitempty"`
// Protocol Policy rule type of the traffic
@@ -4962,7 +4962,7 @@ type RouteRequest struct {
SkipAutoApply *bool `json:"skip_auto_apply,omitempty"`
}
// RulePortRange Policy rule affected ports range
// RulePortRange Policy rule affected ports range. A range with identical start and end values represents a single port.
type RulePortRange struct {
// End The ending port of the range
End int `json:"end"`