mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-21 07:01:30 +02:00
Compare commits
1 Commits
feature/he
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a6efbb5fc |
@@ -15,6 +15,12 @@ set -o pipefail
|
||||
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
|
||||
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
|
||||
#
|
||||
# Step 2 is skipped when the deployment already runs on Postgres
|
||||
# (server.store.engine: postgres in config.yaml). Nothing is provisioned or
|
||||
# migrated in that case and the store config is left exactly as the operator
|
||||
# wrote it — the enterprise image reads the same Postgres the community image
|
||||
# did. Such a deployment gets the image swap, and can still opt into step 3.
|
||||
#
|
||||
# If any step fails once the stack has been touched, the script rolls itself
|
||||
# back automatically: generated files are removed, the Postgres volume this run
|
||||
# created is dropped, and the original deployment is started again.
|
||||
@@ -38,6 +44,18 @@ ENV_BACKUP=""
|
||||
PG_VOLUME_NAME=""
|
||||
BACKUP_DIR=""
|
||||
|
||||
# Store state. STORE_ENGINE is what the deployment runs on today; when it is
|
||||
# already postgres, MIGRATE_POSTGRES stays "no" and nothing is provisioned.
|
||||
# POSTGRES_SERVICE is empty when Postgres lives outside this compose project.
|
||||
STORE_ENGINE=""
|
||||
EXISTING_POSTGRES="no"
|
||||
POSTGRES_DSN=""
|
||||
POSTGRES_SERVICE=""
|
||||
POSTGRES_DEPENDS_CONDITION="service_healthy"
|
||||
# Whether this run needs to generate config.yaml.enterprise at all. A pure
|
||||
# image swap does not.
|
||||
ENTERPRISE_CONFIG="no"
|
||||
|
||||
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
||||
|
||||
check_docker_compose() {
|
||||
@@ -192,6 +210,85 @@ detect_exposed_address() {
|
||||
yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST"
|
||||
}
|
||||
|
||||
# The engine is a config.yaml-only setting — there is no env override for it
|
||||
# (combined/cmd/root.go reads it from YAML and derives the env vars), so
|
||||
# config.yaml is authoritative. Absent means the sqlite default.
|
||||
detect_store_engine() {
|
||||
local engine
|
||||
engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST")
|
||||
if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then
|
||||
engine="sqlite"
|
||||
fi
|
||||
echo "$engine" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
detect_store_dsn() {
|
||||
yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST"
|
||||
}
|
||||
|
||||
# config.yaml is where a combined deployment carries its DSN; this only covers
|
||||
# hand-rolled installs that keep it in the environment instead.
|
||||
detect_store_dsn_from_compose() {
|
||||
# `compose config` re-escapes a literal $ as $$ on the way out, so undo that
|
||||
# to get the value the container actually receives.
|
||||
$DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval "
|
||||
.services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN //
|
||||
.services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\"
|
||||
" - 2>/dev/null | sed 's/\$\$/$/g'
|
||||
}
|
||||
|
||||
# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name".
|
||||
dsn_host() {
|
||||
local dsn="$1"
|
||||
case "$dsn" in
|
||||
*://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;;
|
||||
*) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# flow-enricher is its own container, so a loopback host or a socket path would
|
||||
# reach the enricher rather than Postgres. Only flag hosts we can positively
|
||||
# identify — an unparseable DSN must not leave the operator with no way forward.
|
||||
dsn_host_reachable() {
|
||||
local dsn="$1"
|
||||
case "$(dsn_host "$dsn")" in
|
||||
localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Names the compose service running this deployment's Postgres, for depends_on.
|
||||
# Empty means external — the DSN host matched no service. A DSN with no readable
|
||||
# host falls back to matching on image.
|
||||
detect_postgres_service() {
|
||||
local host
|
||||
host=$(dsn_host "$POSTGRES_DSN")
|
||||
if [[ -n "$host" ]]; then
|
||||
if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then
|
||||
echo "$host"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# depends_on: service_healthy is only legal if the service defines a healthcheck.
|
||||
detect_postgres_depends_condition() {
|
||||
local tag
|
||||
tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null)
|
||||
if [[ "$tag" == "!!map" ]]; then
|
||||
echo "service_healthy"
|
||||
else
|
||||
echo "service_started"
|
||||
fi
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local value="$1"
|
||||
value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g')
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
detect_compose_network() {
|
||||
local tag
|
||||
tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null)
|
||||
@@ -228,16 +325,30 @@ services:
|
||||
NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
EOF
|
||||
|
||||
# An existing Postgres is already wired up by the operator's own compose file,
|
||||
# so only a Postgres this run creates needs a depends_on.
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
${POSTGRES_SERVICE}:
|
||||
condition: ${POSTGRES_DEPENDS_CONDITION}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# The server is only pointed at a different config file when this run
|
||||
# generates one. A pure image swap leaves it on its original config.yaml.
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
volumes:
|
||||
- ./${ENTERPRISE_CONFIG_FILE}:/etc/netbird/config.yaml.enterprise:ro
|
||||
command: ["--config", "/etc/netbird/config.yaml.enterprise"]
|
||||
EOF
|
||||
fi
|
||||
|
||||
postgres:
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${POSTGRES_SERVICE}:
|
||||
image: postgres:17
|
||||
container_name: netbird-postgres
|
||||
restart: unless-stopped
|
||||
@@ -257,6 +368,14 @@ EOF
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Nothing to wait on when Postgres is managed outside this compose project.
|
||||
local enricher_depends=""
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
enricher_depends="
|
||||
${POSTGRES_SERVICE}:
|
||||
condition: ${POSTGRES_DEPENDS_CONDITION}"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
nats:
|
||||
@@ -273,9 +392,7 @@ EOF
|
||||
container_name: netbird-flow-enricher
|
||||
restart: unless-stopped
|
||||
networks: [${COMPOSE_NETWORK}]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
depends_on:${enricher_depends}
|
||||
nats:
|
||||
condition: service_started
|
||||
environment:
|
||||
@@ -283,10 +400,10 @@ EOF
|
||||
NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
NB_DATADIR: /var/lib/netbird
|
||||
NB_MANAGEMENT_STORE_ENGINE: postgres
|
||||
NB_MANAGEMENT_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_STORE_ENGINE_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_MANAGEMENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_STORE_ENGINE_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_TRAFFIC_EVENT_STORE_ENGINE: postgres
|
||||
NB_TRAFFIC_EVENT_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_TRAFFIC_EVENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_MANAGEMENT_STORE_KEY: \${NETBIRD_ENCRYPTION_KEY}
|
||||
NB_FLOW_ADAPTER_TYPE: nats
|
||||
NB_FLOW_NATS_ENDPOINTS: nats://nats:4222
|
||||
@@ -343,27 +460,41 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Build config.yaml.enterprise by yq-editing the operator's existing
|
||||
# config.yaml. We don't touch the original file.
|
||||
# Build config.yaml.enterprise from the operator's existing config.yaml. We
|
||||
# don't touch the original file. Values go through strenv() so a DSN carrying
|
||||
# quotes, backslashes or $ cannot break out of the expression.
|
||||
render_enterprise_config() {
|
||||
local pg_dsn="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
{
|
||||
echo "# Generated by migrate-to-enterprise.sh from ${CONFIG_YAML_HOST}."
|
||||
echo "# The enterprise server is started with --config pointing at this file,"
|
||||
echo "# so later edits to ${CONFIG_YAML_HOST} have no effect until copied here."
|
||||
cat "$CONFIG_YAML_HOST"
|
||||
} > "$ENTERPRISE_CONFIG_FILE"
|
||||
|
||||
yq eval "
|
||||
.server.store.engine = \"postgres\" |
|
||||
.server.store.dsn = \"$pg_dsn\" |
|
||||
.server.activityStore.engine = \"postgres\" |
|
||||
.server.activityStore.dsn = \"$pg_dsn\" |
|
||||
.server.authStore.engine = \"postgres\" |
|
||||
.server.authStore.dsn = \"$pg_dsn\"
|
||||
" "$CONFIG_YAML_HOST" > "$ENTERPRISE_CONFIG_FILE"
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
# Fresh Postgres: point every store section at it. migrate-store carries the
|
||||
# SQLite contents across.
|
||||
POSTGRES_DSN="$POSTGRES_DSN" yq eval -i '
|
||||
.server.store.engine = "postgres" |
|
||||
.server.store.dsn = strenv(POSTGRES_DSN) |
|
||||
.server.activityStore.engine = "postgres" |
|
||||
.server.activityStore.dsn = strenv(POSTGRES_DSN) |
|
||||
.server.authStore.engine = "postgres" |
|
||||
.server.authStore.dsn = strenv(POSTGRES_DSN)
|
||||
' "$ENTERPRISE_CONFIG_FILE"
|
||||
fi
|
||||
# Otherwise the store config is the operator's and stays untouched.
|
||||
# activityStore and authStore do not inherit from server.store — each falls
|
||||
# back to its own SQLite file under dataDir — so repointing them at Postgres
|
||||
# here would silently strand the existing audit log and the embedded IdP's
|
||||
# users, with no migrate-store run to carry them over.
|
||||
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
local flow_addr="${NETBIRD_DOMAIN}"
|
||||
yq eval -i "
|
||||
NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i '
|
||||
.server.trafficFlow.enabled = true |
|
||||
.server.trafficFlow.address = \"$flow_addr\" |
|
||||
.server.trafficFlow.interval = \"60s\"
|
||||
" "$ENTERPRISE_CONFIG_FILE"
|
||||
.server.trafficFlow.address = strenv(NETBIRD_DOMAIN) |
|
||||
.server.trafficFlow.interval = "60s"
|
||||
' "$ENTERPRISE_CONFIG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -630,6 +761,91 @@ on_exit() {
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Already on Postgres: there is nothing to provision and nothing to migrate.
|
||||
# The enterprise image reads the very same store config the community image
|
||||
# did, so step 2 collapses to a no-op and the run is a plain image swap.
|
||||
configure_existing_postgres() {
|
||||
EXISTING_POSTGRES="yes"
|
||||
MIGRATE_POSTGRES="no"
|
||||
|
||||
# DSN first — detect_postgres_service prefers the host it names.
|
||||
POSTGRES_DSN=$(detect_store_dsn)
|
||||
if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then
|
||||
POSTGRES_DSN=$(detect_store_dsn_from_compose)
|
||||
fi
|
||||
if [[ "$POSTGRES_DSN" == "null" ]]; then
|
||||
POSTGRES_DSN=""
|
||||
fi
|
||||
|
||||
POSTGRES_SERVICE=$(detect_postgres_service)
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
|
||||
fi
|
||||
|
||||
echo "Step 2: Postgres migration not needed — this deployment already runs on"
|
||||
echo " Postgres. Its store configuration is reused as-is and left"
|
||||
echo " untouched; no database is created and no data is moved."
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
echo " Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)"
|
||||
else
|
||||
echo " Postgres service: managed outside $COMPOSE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_sqlite_store() {
|
||||
MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
|
||||
|
||||
# The override would otherwise merge into a service of the same name and
|
||||
# quietly rewrite its image and credentials.
|
||||
local existing
|
||||
existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE")
|
||||
if [[ "$existing" == "true" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr
|
||||
echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr
|
||||
echo "'postgres' service and Compose would merge the two." > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr
|
||||
echo "then re-run." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
|
||||
echo " will be backed up automatically. To fully revert later, restore"
|
||||
echo " that backup and delete docker-compose.override.yml +"
|
||||
echo " config.yaml.enterprise."
|
||||
local confirm
|
||||
confirm=$(read_yes_no " Continue?" "y")
|
||||
if [[ "$confirm" != "yes" ]]; then
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " Skipping Postgres migration."
|
||||
return 0
|
||||
fi
|
||||
|
||||
POSTGRES_PASSWORD=$(rand_password)
|
||||
POSTGRES_SERVICE="postgres"
|
||||
POSTGRES_DEPENDS_CONDITION="service_healthy"
|
||||
POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
}
|
||||
|
||||
# mysql, or something this script has never seen. Swapping the images is still
|
||||
# valid; touching the store is not.
|
||||
configure_unsupported_store() {
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " ⚠ server.store.engine is '$STORE_ENGINE'. This script only migrates"
|
||||
echo " SQLite to Postgres, and traffic flow requires Postgres, so both are"
|
||||
echo " unavailable here. The store configuration will be left untouched."
|
||||
echo ""
|
||||
local proceed
|
||||
proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n")
|
||||
if [[ "$proceed" != "yes" ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
init_migration() {
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
check_yq
|
||||
@@ -679,12 +895,15 @@ init_migration() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STORE_ENGINE=$(detect_store_engine)
|
||||
|
||||
echo "Detected existing deployment:"
|
||||
echo " Combined service: $COMBINED_SERVICE"
|
||||
echo " Dashboard: $DASHBOARD_SERVICE"
|
||||
echo " config.yaml: $CONFIG_YAML_HOST"
|
||||
echo " Data volume: $DATA_VOLUME"
|
||||
echo " Network: $COMPOSE_NETWORK"
|
||||
echo " Store engine: $STORE_ENGINE"
|
||||
echo ""
|
||||
|
||||
require_eula_acceptance
|
||||
@@ -703,28 +922,17 @@ init_migration() {
|
||||
echo "Step 1: Image swap (community → Enterprise). License key required."
|
||||
NB_LICENSE_KEY=$(read_secret " License key")
|
||||
|
||||
# Step 2 — optional
|
||||
# Step 2 — what this does depends on what the deployment already stores in.
|
||||
echo ""
|
||||
MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
echo ""
|
||||
echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
|
||||
echo " will be backed up automatically. To fully revert later, restore"
|
||||
echo " that backup and delete docker-compose.override.yml +"
|
||||
echo " config.yaml.enterprise."
|
||||
local confirm
|
||||
confirm=$(read_yes_no " Continue?" "y")
|
||||
if [[ "$confirm" != "yes" ]]; then
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " Skipping Postgres migration."
|
||||
else
|
||||
POSTGRES_PASSWORD=$(rand_password)
|
||||
fi
|
||||
fi
|
||||
case "$STORE_ENGINE" in
|
||||
postgres) configure_existing_postgres ;;
|
||||
sqlite) configure_sqlite_store ;;
|
||||
*) configure_unsupported_store ;;
|
||||
esac
|
||||
|
||||
# Step 3 — optional, only if Postgres is on (flow requires Postgres)
|
||||
echo ""
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n")
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Auth secret MUST match server.authSecret from config.yaml
|
||||
@@ -748,12 +956,46 @@ init_migration() {
|
||||
echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# flow-enricher talks to Postgres directly, so this is the one place an
|
||||
# existing deployment's DSN is actually needed — and the one place a host
|
||||
# that only works from inside the server container shows up.
|
||||
while :; do
|
||||
local dsn_problem=""
|
||||
if [[ -z "$POSTGRES_DSN" ]]; then
|
||||
dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment."
|
||||
elif ! dsn_host_reachable "$POSTGRES_DSN"; then
|
||||
dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container."
|
||||
fi
|
||||
[[ -n "$dsn_problem" ]] || break
|
||||
|
||||
echo ""
|
||||
echo " The flow enricher reaches Postgres from a container of its own."
|
||||
echo " $dsn_problem"
|
||||
echo " Enter a DSN reachable from other containers, or press Ctrl-C to abort."
|
||||
POSTGRES_DSN=$(read_required " Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)")
|
||||
done
|
||||
|
||||
# Only where the operator owns Postgres: a DSN entered above may name a
|
||||
# different host. The sqlite path creates its own service, nothing to find.
|
||||
if [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
POSTGRES_SERVICE=$(detect_postgres_service)
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
ENABLE_FLOW="no"
|
||||
echo "Step 3 (traffic flow) skipped — requires Postgres."
|
||||
fi
|
||||
|
||||
# config.yaml.enterprise only exists to hold changes; without any there is
|
||||
# nothing to generate and the server keeps running on its own config.yaml.
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
ENTERPRISE_CONFIG="yes"
|
||||
fi
|
||||
|
||||
check_data_directory
|
||||
check_stale_postgres_volume
|
||||
}
|
||||
@@ -771,7 +1013,7 @@ apply_changes() {
|
||||
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak"
|
||||
fi
|
||||
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
echo "Writing $ENTERPRISE_CONFIG_FILE ..."
|
||||
install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE"
|
||||
render_enterprise_config
|
||||
@@ -807,6 +1049,9 @@ apply_changes() {
|
||||
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
fi
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a
|
||||
# deployment already setting that one keeps its own value.
|
||||
echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")"
|
||||
echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}"
|
||||
echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}"
|
||||
fi
|
||||
@@ -868,14 +1113,19 @@ print_summary() {
|
||||
echo " Summary"
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
echo " Images: swapped to enterprise"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)"
|
||||
[[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)"
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
echo " Storage: Postgres (data migrated from SQLite)"
|
||||
elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
echo " Storage: Postgres (pre-existing, configuration unchanged)"
|
||||
else
|
||||
echo " Storage: $STORE_ENGINE (unchanged)"
|
||||
fi
|
||||
[[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled"
|
||||
[[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled"
|
||||
echo ""
|
||||
echo " Generated files (next to your docker-compose.yml):"
|
||||
echo " $OVERRIDE_FILE"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||
[[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||
echo " .env (license key + secrets, mode 600)"
|
||||
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
|
||||
@@ -899,7 +1149,11 @@ print_summary() {
|
||||
else
|
||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||
fi
|
||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||
else
|
||||
echo " rm -f $OVERRIDE_FILE"
|
||||
fi
|
||||
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
|
||||
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
|
||||
elif [[ "$ENV_EXISTED" == "no" ]]; then
|
||||
|
||||
@@ -1311,7 +1311,7 @@ func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID s
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return true, proxyauth.HeaderUserID, proxyauth.MethodHeader
|
||||
return true, "header-user", proxyauth.MethodHeader
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
|
||||
@@ -30,12 +30,6 @@ const (
|
||||
SessionJWTIssuer = "netbird-management"
|
||||
)
|
||||
|
||||
// HeaderUserID is the synthetic user id recorded for header-authenticated
|
||||
// requests. Header auth validates a per-service secret and resolves no user
|
||||
// record, so proxy access logs and management-minted session tokens both
|
||||
// attribute the request to this id.
|
||||
const HeaderUserID = "header-user"
|
||||
|
||||
// ResolveProto determines the protocol scheme based on the forwarded proto
|
||||
// configuration. When set to "http" or "https" the value is used directly.
|
||||
// Otherwise TLS state is used: if conn is non-nil "https" is returned, else "http".
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// Header implements header-based authentication. The service mapping carries
|
||||
// the argon2id hash of every value accepted for the header, so the proxy
|
||||
// verifies the credential locally rather than round-tripping to management.
|
||||
// ErrHeaderAuthFailed indicates that the header was present but the
|
||||
// credential did not validate. Callers should return 401 instead of
|
||||
// falling through to other auth schemes.
|
||||
var ErrHeaderAuthFailed = errors.New("header authentication failed")
|
||||
|
||||
// Header implements header-based authentication. The proxy checks for the
|
||||
// configured header in each request and validates its value via gRPC.
|
||||
type Header struct {
|
||||
id types.ServiceID
|
||||
accountId types.AccountID
|
||||
headerName string
|
||||
hashes []string
|
||||
verified *verifiedValues
|
||||
client authenticator
|
||||
}
|
||||
|
||||
// NewHeader creates a Header authentication scheme accepting any value whose
|
||||
// argon2id hash appears in hashes. An empty hashes slice rejects every request
|
||||
// carrying the header, so a mapping that arrived without its hashes fails
|
||||
// closed instead of leaving the service unprotected.
|
||||
func NewHeader(headerName string, hashes []string) Header {
|
||||
// NewHeader creates a Header authentication scheme for the given header name.
|
||||
func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header {
|
||||
return Header{
|
||||
headerName: http.CanonicalHeaderKey(headerName),
|
||||
hashes: hashes,
|
||||
verified: &verifiedValues{seen: make(map[[32]byte]struct{}, len(hashes))},
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
headerName: headerName,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,55 +39,31 @@ func (Header) Type() auth.Method {
|
||||
return auth.MethodHeader
|
||||
}
|
||||
|
||||
// Authenticate satisfies Scheme. Header credentials are resolved by Verify
|
||||
// before the scheme loop runs, so a request that reaches here never carries
|
||||
// the header and there is no credential to prompt for.
|
||||
func (Header) Authenticate(*http.Request) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// Verify reports whether the request carries the configured header and, when
|
||||
// it does, whether the value matches one of the service's hashes.
|
||||
func (h Header) Verify(r *http.Request) (present, matched bool) {
|
||||
// Authenticate checks for the configured header in the request. If absent,
|
||||
// returns empty (unauthenticated). If present, validates via gRPC.
|
||||
func (h Header) Authenticate(r *http.Request) (string, string, error) {
|
||||
value := r.Header.Get(h.headerName)
|
||||
if value == "" {
|
||||
return false, false
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
if h.verified.has(digest) {
|
||||
return true, true
|
||||
res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
||||
Id: string(h.id),
|
||||
AccountId: string(h.accountId),
|
||||
Request: &proto.AuthenticateRequest_HeaderAuth{
|
||||
HeaderAuth: &proto.HeaderAuthRequest{
|
||||
HeaderValue: value,
|
||||
HeaderName: h.headerName,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("authenticate header: %w", err)
|
||||
}
|
||||
|
||||
for _, hash := range h.hashes {
|
||||
if argon2id.Verify(value, hash) == nil {
|
||||
h.verified.add(digest)
|
||||
return true, true
|
||||
}
|
||||
if res.GetSuccess() {
|
||||
return res.GetSessionToken(), "", nil
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
// verifiedValues remembers which header values already passed argon2id
|
||||
// verification. argon2id is deliberately expensive (19 MiB, two passes) and
|
||||
// header credentials repeat on every request, so re-deriving per request would
|
||||
// dominate the hot path. The set cannot outgrow the number of configured
|
||||
// hashes, and a mapping update builds a fresh scheme with an empty set.
|
||||
// Values are keyed by digest so the plaintext credential is not retained.
|
||||
type verifiedValues struct {
|
||||
mu sync.Mutex
|
||||
seen map[[32]byte]struct{}
|
||||
}
|
||||
|
||||
func (v *verifiedValues) has(digest [32]byte) bool {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
_, ok := v.seen[digest]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (v *verifiedValues) add(digest [32]byte) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.seen[digest] = struct{}{}
|
||||
return "", "", ErrHeaderAuthFailed
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if mw.forwardWithHeaderAuth(w, r, config, next) {
|
||||
if mw.forwardWithHeaderAuth(w, r, host, config, next) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,16 +325,6 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Header auth is checked per request against the mapping's hashes and mints
|
||||
// no session, so a header-method token can only predate that. Honouring it
|
||||
// would keep a rotated credential working until the token expired.
|
||||
if method == auth.MethodHeader.String() {
|
||||
mw.logger.WithField("host", host).
|
||||
Debug("ignoring header-auth session cookie; the header is required on every request")
|
||||
return false
|
||||
}
|
||||
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetUserID(userID)
|
||||
cd.SetUserEmail(email)
|
||||
@@ -446,14 +436,14 @@ func isTunnelSourceIP(ip netip.Addr) bool {
|
||||
|
||||
// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates,
|
||||
// the request is forwarded directly (no redirect), which is important for API clients.
|
||||
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, config DomainConfig, next http.Handler) bool {
|
||||
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
|
||||
for _, scheme := range config.Schemes {
|
||||
hdr, ok := scheme.(Header)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
handled := mw.tryHeaderScheme(w, r, hdr, next)
|
||||
handled := mw.tryHeaderScheme(w, r, host, config, hdr, next)
|
||||
if handled {
|
||||
return true
|
||||
}
|
||||
@@ -461,27 +451,40 @@ func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Reque
|
||||
return false
|
||||
}
|
||||
|
||||
// tryHeaderScheme verifies the credential against the hashes the service
|
||||
// mapping carries. No session token is issued: the credential travels on
|
||||
// every request, so there is nothing for a cookie to save.
|
||||
func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, hdr Header, next http.Handler) bool {
|
||||
present, matched := hdr.Verify(r)
|
||||
if !present {
|
||||
func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool {
|
||||
token, _, err := hdr.Authenticate(r)
|
||||
if err != nil {
|
||||
return mw.handleHeaderAuthError(w, r, err)
|
||||
}
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if !matched {
|
||||
mw.logger.WithFields(log.Fields{
|
||||
"host": r.Host,
|
||||
"header": hdr.headerName,
|
||||
}).Debug("header auth rejected: value does not match any configured hash")
|
||||
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
|
||||
if err != nil {
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
status := http.StatusBadRequest
|
||||
msg := "invalid session token"
|
||||
if errors.Is(err, errValidationUnavailable) {
|
||||
status = http.StatusBadGateway
|
||||
msg = "authentication service unavailable"
|
||||
}
|
||||
http.Error(w, msg, status)
|
||||
return true
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
|
||||
setSessionCookie(w, token, config.SessionExpiration)
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetUserID(auth.HeaderUserID)
|
||||
cd.SetUserID(result.UserID)
|
||||
cd.SetUserEmail(result.UserEmail)
|
||||
cd.SetUserGroups(result.Groups)
|
||||
cd.SetUserGroupNames(result.GroupNames)
|
||||
cd.SetAuthMethod(auth.MethodHeader.String())
|
||||
}
|
||||
|
||||
@@ -489,6 +492,20 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, hd
|
||||
return true
|
||||
}
|
||||
|
||||
func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||
if errors.Is(err, ErrHeaderAuthFailed) {
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err)
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
}
|
||||
http.Error(w, "authentication service unavailable", http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
|
||||
func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) {
|
||||
cd := proxy.CapturedDataFromContext(ctx)
|
||||
if cd == nil {
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -1024,24 +1023,38 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "should show login page when multiple methods exist")
|
||||
}
|
||||
|
||||
// newHeaderScheme creates a Header scheme accepting each of the given values,
|
||||
// hashed the way management hashes them before putting them on the mapping.
|
||||
func newHeaderScheme(t *testing.T, headerName string, acceptedValues ...string) Header {
|
||||
// mockAuthenticator is a minimal mock for the authenticator gRPC interface
|
||||
// used by the Header scheme.
|
||||
type mockAuthenticator struct {
|
||||
fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) {
|
||||
return m.fn(ctx, in)
|
||||
}
|
||||
|
||||
// newHeaderSchemeWithToken creates a Header scheme backed by a mock that
|
||||
// returns a signed session token when the expected header value is provided.
|
||||
func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
|
||||
t.Helper()
|
||||
hashes := make([]string, 0, len(acceptedValues))
|
||||
for _, v := range acceptedValues {
|
||||
hash, err := argon2id.Hash(v)
|
||||
require.NoError(t, err, "hashing an accepted header value must succeed")
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
return NewHeader(headerName, hashes)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
ha := req.GetHeaderAuth()
|
||||
if ha != nil && ha.GetHeaderValue() == expectedValue {
|
||||
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
|
||||
}
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
return NewHeader(mock, "svc1", "acc1", headerName)
|
||||
}
|
||||
|
||||
func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
@@ -1062,12 +1075,19 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "ok", rec.Body.String())
|
||||
|
||||
// The credential rides on every request, so no session cookie is issued.
|
||||
// Session cookie should be set.
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
assert.NotEqual(t, auth.SessionCookieName, c.Name, "header auth must not issue a session cookie")
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth")
|
||||
assert.True(t, sessionCookie.HttpOnly)
|
||||
assert.True(t, sessionCookie.Secure)
|
||||
|
||||
assert.Equal(t, auth.HeaderUserID, capturedData.GetUserID())
|
||||
assert.Equal(t, "header-user", capturedData.GetUserID())
|
||||
assert.Equal(t, "header", capturedData.GetAuthMethod())
|
||||
}
|
||||
|
||||
@@ -1075,7 +1095,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
// Also add a PIN scheme so we can verify fallthrough behavior.
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
@@ -1094,7 +1114,10 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -1108,157 +1131,93 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Equal(t, "header", capturedData.GetAuthMethod())
|
||||
assert.Empty(t, hdr.verified.seen, "a rejected value must not be memoized")
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_NoHashesFailsClosed covers a mapping that names a
|
||||
// header but carries no hash for it: the check cannot be evaluated, so the
|
||||
// request must be denied rather than let through unauthenticated.
|
||||
func TestProtect_HeaderAuth_NoHashesFailsClosed(t *testing.T) {
|
||||
func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := NewHeader("X-API-Key", nil)
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
return nil, errors.New("gRPC unavailable")
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
backendCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set("X-API-Key", "any-key")
|
||||
req.Header.Set("X-API-Key", "some-key")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.False(t, backendCalled, "a header auth with no hashes must not admit the request")
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_SubsequentRequestRequiresHeader verifies that header
|
||||
// auth grants no ambient session: a follow-up request that drops the header is
|
||||
// treated as unauthenticated.
|
||||
func TestProtect_HeaderAuth_SubsequentRequestRequiresHeader(t *testing.T) {
|
||||
func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalls int
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
backendCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// First request with header auth.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req1.Header.Set("X-API-Key", "secret-key")
|
||||
req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData("")))
|
||||
rec1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
require.Equal(t, 1, backendCalls)
|
||||
|
||||
// Same client, second request, header omitted: no cookie was handed out, so
|
||||
// there is nothing to carry the earlier success forward.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
|
||||
// Extract session cookie.
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec1.Result().Cookies() {
|
||||
req2.AddCookie(c)
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
// Second request with only the session cookie (no header).
|
||||
capturedData2 := proxy.NewCapturedData("")
|
||||
req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
|
||||
req2.AddCookie(sessionCookie)
|
||||
req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2))
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec2.Code, "dropping the header must revoke access")
|
||||
assert.Equal(t, 1, backendCalls, "backend must not be reached without the header")
|
||||
assert.Equal(t, http.StatusOK, rec2.Code)
|
||||
assert.Equal(t, "header-user", capturedData2.GetUserID())
|
||||
assert.Equal(t, "header", capturedData2.GetAuthMethod())
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_LegacySessionCookieIsIgnored covers the upgrade
|
||||
// window. Header auth used to mint a session token, so cookies with
|
||||
// method=header survive a proxy upgrade and stay signature-valid for their full
|
||||
// lifetime. They must not stand in for the header, or a credential rotated
|
||||
// right after the upgrade would keep working until every such token expired.
|
||||
func TestProtect_HeaderAuth_LegacySessionCookieIsIgnored(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
// A token management would have minted for header auth before the upgrade.
|
||||
legacyToken, err := sessionkey.SignToken(kp.PrivateKey, auth.HeaderUserID, "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
var backendCalls int
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
backendCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
t.Run("cookie alone is rejected", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: legacyToken})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "a header-auth cookie must not authenticate on its own")
|
||||
assert.Equal(t, 0, backendCalls, "backend must not be reached without the header")
|
||||
})
|
||||
|
||||
t.Run("cookie does not block the header path", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: legacyToken})
|
||||
req.Header.Set("X-API-Key", "secret-key")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "a client sending both must still be admitted by the header")
|
||||
assert.Equal(t, 1, backendCalls)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_RepeatedValueIsMemoized verifies the KDF is run once
|
||||
// per distinct accepted value. argon2id is deliberately expensive, so a
|
||||
// credential that repeats on every request must not be re-derived each time.
|
||||
func TestProtect_HeaderAuth_RepeatedValueIsMemoized(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
get := func(value string) int {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set("X-API-Key", value)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusOK, get("key-a"))
|
||||
require.Equal(t, http.StatusOK, get("key-a"))
|
||||
assert.Len(t, hdr.verified.seen, 1, "the same value must be memoized once")
|
||||
|
||||
require.Equal(t, http.StatusOK, get("key-b"))
|
||||
assert.Len(t, hdr.verified.seen, 2, "each accepted value gets its own entry")
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, get("key-c"))
|
||||
assert.Len(t, hdr.verified.seen, 2, "rejected values must not grow the set")
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that a service with
|
||||
// several accepted credentials for one header name accepts any of them.
|
||||
// Management applied these OR semantics while it still validated the value; the
|
||||
// proxy preserves them by carrying every hash for a name on one scheme.
|
||||
// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that the proxy
|
||||
// correctly handles multiple valid credentials for the same header name.
|
||||
// In production, the mgmt gRPC authenticateHeader iterates all configured
|
||||
// header auths and accepts if any hash matches (OR semantics). The proxy
|
||||
// creates one Header scheme per entry, but a single gRPC call checks all.
|
||||
func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b")
|
||||
// Mock simulates mgmt behavior: accepts either token-a or token-b.
|
||||
accepted := map[string]bool{"Bearer token-a": true, "Bearer token-b": true}
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
ha := req.GetHeaderAuth()
|
||||
if ha != nil && accepted[ha.GetHeaderValue()] {
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
|
||||
}
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
|
||||
// Single Header scheme (as if one entry existed), but the mock checks both values.
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -2063,7 +2062,9 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
if mapping.GetAuth().GetOidc() {
|
||||
schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto))
|
||||
}
|
||||
schemes = append(schemes, headerAuthSchemes(mapping.GetAuth().GetHeaderAuths())...)
|
||||
for _, ha := range mapping.GetAuth().GetHeaderAuths() {
|
||||
schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader()))
|
||||
}
|
||||
|
||||
ipRestrictions := s.parseRestrictions(mapping)
|
||||
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
||||
@@ -2079,34 +2080,6 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerAuthSchemes builds one scheme per canonical header name, carrying every
|
||||
// hash configured for that name so any of them is accepted — the OR semantics
|
||||
// management applied while it still validated the credential itself. A name
|
||||
// whose entries arrive without a hash yields a scheme with none, which rejects
|
||||
// the header rather than leaving the service unprotected.
|
||||
func headerAuthSchemes(headerAuths []*proto.HeaderAuth) []auth.Scheme {
|
||||
names := make([]string, 0, len(headerAuths))
|
||||
hashes := make(map[string][]string, len(headerAuths))
|
||||
for _, ha := range headerAuths {
|
||||
name := http.CanonicalHeaderKey(ha.GetHeader())
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(names, name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
if hash := ha.GetHashedValue(); hash != "" {
|
||||
hashes[name] = append(hashes[name], hash)
|
||||
}
|
||||
}
|
||||
|
||||
schemes := make([]auth.Scheme, 0, len(names))
|
||||
for _, name := range names {
|
||||
schemes = append(schemes, auth.NewHeader(name, hashes[name]))
|
||||
}
|
||||
return schemes
|
||||
}
|
||||
|
||||
// initMiddlewareManager wires the middleware subsystem at boot. It configures
|
||||
// the per-process FactoryContext concrete middlewares consult, installs the
|
||||
// live-service check, and binds the resolver to the registry concrete
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,10 +15,8 @@ import (
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -213,50 +209,6 @@ func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
|
||||
assert.Empty(t, redacted.Path, "empty Path must remain empty")
|
||||
}
|
||||
|
||||
// headerSchemeAccepts reports whether the scheme admits value for headerName.
|
||||
func headerSchemeAccepts(t *testing.T, scheme auth.Scheme, headerName, value string) bool {
|
||||
t.Helper()
|
||||
hdr, ok := scheme.(auth.Header)
|
||||
require.True(t, ok, "header auths must produce Header schemes")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set(headerName, value)
|
||||
_, matched := hdr.Verify(req)
|
||||
return matched
|
||||
}
|
||||
|
||||
func TestHeaderAuthSchemes_GroupsValuesByCanonicalHeaderName(t *testing.T) {
|
||||
hashOf := func(v string) string {
|
||||
hash, err := argon2id.Hash(v)
|
||||
require.NoError(t, err)
|
||||
return hash
|
||||
}
|
||||
|
||||
schemes := headerAuthSchemes([]*proto.HeaderAuth{
|
||||
{Header: "Authorization", HashedValue: hashOf("Bearer a")},
|
||||
{Header: "authorization", HashedValue: hashOf("Bearer b")},
|
||||
{Header: "X-Api-Key", HashedValue: hashOf("key-1")},
|
||||
})
|
||||
|
||||
require.Len(t, schemes, 2, "entries differing only in header-name case must collapse into one scheme")
|
||||
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer a"), "first value for the header must be accepted")
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer b"), "second value for the same header must be accepted")
|
||||
assert.False(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer c"), "unconfigured value must be rejected")
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[1], "X-Api-Key", "key-1"), "a second header name keeps its own scheme")
|
||||
}
|
||||
|
||||
// TestHeaderAuthSchemes_MissingHashFailsClosed covers a mapping that names a
|
||||
// header but carries no hash for it. Dropping the scheme would leave a service
|
||||
// whose only auth is that header wide open, so the scheme is kept and denies.
|
||||
func TestHeaderAuthSchemes_MissingHashFailsClosed(t *testing.T) {
|
||||
schemes := headerAuthSchemes([]*proto.HeaderAuth{{Header: "X-Api-Key"}})
|
||||
|
||||
require.Len(t, schemes, 1, "a header without a hash must still register a scheme")
|
||||
assert.False(t, headerSchemeAccepts(t, schemes[0], "X-Api-Key", "anything"),
|
||||
"a header auth without a hash must reject every value")
|
||||
}
|
||||
|
||||
type statusUpdateOnlyClient struct {
|
||||
proto.ProxyServiceClient
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user