Compare commits

..

6 Commits

24 changed files with 832 additions and 358 deletions

View File

@@ -37,3 +37,16 @@ jobs:
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'
trigger_dashboard_bump:
runs-on: ubuntu-latest
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger dashboard wasm client bump
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/dashboard
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'

View File

@@ -91,6 +91,13 @@ type Options struct {
// when the embedded client must never act as a stepping stone into
// the host's local network (e.g. the proxy's overlay peer).
BlockLANAccess bool
// LazyConnectionEnabled is a tri-state local override for lazy connections,
// mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
// flag; a set value overrides it in both directions. A short-lived client
// that reaches only a few known peers can set this to false, so its peers
// connect eagerly and the first request does not wait for the connection to
// be established.
LazyConnectionEnabled *bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -220,6 +227,15 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
if opts.LazyConnectionEnabled != nil {
// Runtime-only override, read back through lazyconn.ParseState; a set value
// wins over the management feature flag in both directions.
config.LazyConnection = "off"
if *opts.LazyConnectionEnabled {
config.LazyConnection = "on"
}
}
if opts.Performance.PreallocatedBuffersPerPool != nil {
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
}

View File

@@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() {
}
// GetInfo retrieves system information for WASM environment
func GetInfo(_ context.Context) *Info {
func GetInfo(ctx context.Context) *Info {
info := &Info{
GoOS: runtime.GOOS,
Kernel: runtime.GOARCH,
@@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info {
collectBrowserInfo(info)
collectLocationInfo(info)
collectSystemInfo(info)
// A caller-provided device name wins, as on the other platforms. A peer
// registered over an API keeps reporting the name it was registered with,
// so its meta does not change on the first sync.
if name := extractDeviceName(ctx, info.Hostname); name != "" {
info.Hostname = name
}
return info
}

View File

@@ -0,0 +1,27 @@
//go:build js
package system
import (
"context"
"testing"
)
// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the
// reported hostname, so a peer registered over an API keeps reporting the name
// it was registered with instead of renaming itself on its first sync.
func TestGetInfoHonorsDeviceName(t *testing.T) {
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name")
if got := GetInfo(ctx).Hostname; got != "session-name" {
t.Errorf("hostname should carry the caller's device name, got %q", got)
}
}
// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of
// always setting the context value: an empty name must not blank the hostname.
func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) {
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "")
if got := GetInfo(ctx).Hostname; got == "" {
t.Error("an empty device name must not blank the hostname")
}
}

View File

@@ -1,3 +1,5 @@
//go:build windows || (linux && !android) || (darwin && !ios) || freebsd
package system
import (

View File

@@ -764,7 +764,19 @@
"message": "Sensible Informationen anonymisieren"
},
"settings.troubleshooting.anonymize.help": {
"message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs."
"message": "Verbirgt IP-Adressen, Domains und andere sensible Werte."
},
"settings.troubleshooting.anonymize.info": {
"message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben."
},
"settings.troubleshooting.anonymize.none": {
"message": "Keine"
},
"settings.troubleshooting.anonymize.default": {
"message": "Standard"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strikt"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Systeminformationen einschließen"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
"settings.ssh.privilege.hint": {
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.oneWay": {
"message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Anonimizar información sensible"
},
"settings.troubleshooting.anonymize.help": {
"message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros."
"message": "Oculta direcciones IP, dominios y otros valores sensibles."
},
"settings.troubleshooting.anonymize.info": {
"message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización."
},
"settings.troubleshooting.anonymize.none": {
"message": "Ninguno"
},
"settings.troubleshooting.anonymize.default": {
"message": "Predeterminado"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Estricto"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir información del sistema"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "La operación falló."
},
"settings.ssh.privilege.hint": {
"message": "Requiere {actor}. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.oneWay": {
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Anonymiser les informations sensibles"
},
"settings.troubleshooting.anonymize.help": {
"message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux."
"message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles."
},
"settings.troubleshooting.anonymize.info": {
"message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation."
},
"settings.troubleshooting.anonymize.none": {
"message": "Aucune"
},
"settings.troubleshooting.anonymize.default": {
"message": "Par défaut"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strict"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Inclure les informations système"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"settings.ssh.privilege.hint": {
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.oneWay": {
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Vous pouvez lactiver, mais le désactiver de nouveau nécessite {actor} :"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Érzékeny információk anonimizálása"
},
"settings.troubleshooting.anonymize.help": {
"message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban."
"message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket."
},
"settings.troubleshooting.anonymize.info": {
"message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nincs"
},
"settings.troubleshooting.anonymize.default": {
"message": "Alapértelmezett"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Szigorú"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Rendszerinformációk beillesztése"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
"settings.ssh.privilege.hint": {
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
},
"settings.ssh.privilege.oneWay": {
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Anonimizza informazioni sensibili"
},
"settings.troubleshooting.anonymize.help": {
"message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log."
"message": "Nasconde indirizzi IP, domini e altri valori sensibili."
},
"settings.troubleshooting.anonymize.info": {
"message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nessuna"
},
"settings.troubleshooting.anonymize.default": {
"message": "Predefinito"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Rigoroso"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Includi informazioni di sistema"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
},
"settings.ssh.privilege.hint": {
"message": "Richiede {actor}. Esegua invece questo:"
},
"settings.ssh.privilege.oneWay": {
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "機密情報を匿名化"
},
"settings.troubleshooting.anonymize.help": {
"message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。"
"message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。"
},
"settings.troubleshooting.anonymize.info": {
"message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。"
},
"settings.troubleshooting.anonymize.none": {
"message": "なし"
},
"settings.troubleshooting.anonymize.default": {
"message": "デフォルト"
},
"settings.troubleshooting.anonymize.strict": {
"message": "厳格"
},
"settings.troubleshooting.systemInfo.label": {
"message": "システム情報を含める"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
},
"settings.ssh.privilege.hint": {
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.oneWay": {
"message": "無効にはできますが、再度有効にするには{actor}が必要です:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "有効にはできますが、再度無効にするには{actor}が必要です:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Anonimizar informações sensíveis"
},
"settings.troubleshooting.anonymize.help": {
"message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs."
"message": "Oculta endereços IP, domínios e outros valores sensíveis."
},
"settings.troubleshooting.anonymize.info": {
"message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nenhum"
},
"settings.troubleshooting.anonymize.default": {
"message": "Padrão"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Estrito"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir informações do sistema"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "A operação falhou."
},
"settings.ssh.privilege.hint": {
"message": "Requer {actor}. Execute isto em vez disso:"
},
"settings.ssh.privilege.oneWay": {
"message": "Você pode desativar isto, mas ativar novamente requer {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Você pode ativar isto, mas desativar novamente requer {actor}:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "Анонимизировать конфиденциальную информацию"
},
"settings.troubleshooting.anonymize.help": {
"message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах."
"message": "Скрывает IP-адреса, домены и другие конфиденциальные значения."
},
"settings.troubleshooting.anonymize.info": {
"message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации."
},
"settings.troubleshooting.anonymize.none": {
"message": "Нет"
},
"settings.troubleshooting.anonymize.default": {
"message": "По умолчанию"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Строгий"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Включить сведения о системе"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
"settings.ssh.privilege.hint": {
"message": "Требуются {actor}. Выполните вместо этого:"
},
"settings.ssh.privilege.oneWay": {
"message": "Отключить можно, но чтобы включить снова, нужны {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Включить можно, но чтобы отключить снова, нужны {actor}:"
}
}

View File

@@ -764,7 +764,19 @@
"message": "匿名化敏感信息"
},
"settings.troubleshooting.anonymize.help": {
"message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。"
"message": "隐藏 IP 地址、域名和其他敏感值。"
},
"settings.troubleshooting.anonymize.info": {
"message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。"
},
"settings.troubleshooting.anonymize.none": {
"message": "无"
},
"settings.troubleshooting.anonymize.default": {
"message": "默认"
},
"settings.troubleshooting.anonymize.strict": {
"message": "严格"
},
"settings.troubleshooting.systemInfo.label": {
"message": "包含系统信息"
@@ -1338,5 +1350,14 @@
},
"error.unknown": {
"message": "操作失败。"
},
"settings.ssh.privilege.hint": {
"message": "需要{actor}。请改为运行:"
},
"settings.ssh.privilege.oneWay": {
"message": "您可以关闭此项,但重新开启需要{actor}"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "您可以开启此项,但再次关闭需要{actor}"
}
}

View File

@@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error {
// parseClientOptions extracts NetBird options from JavaScript object
func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options := netbird.Options{
DeviceName: "dashboard-client",
LogLevel: defaultLogLevel,
LogLevel: defaultLogLevel,
}
if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() {
@@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options.DeviceName = deviceName.String()
}
if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() {
options.DisableIPv6 = disableIPv6.Bool()
disableIPv6, err := boolOption(jsOptions, "disableIPv6")
if err != nil {
return options, err
}
if disableIPv6 != nil {
options.DisableIPv6 = *disableIPv6
}
// The caller decides whether this client uses lazy connections; left unset it
// defers to the management feature flag. A short-lived, interactive caller
// turns it off so its sessions reach the few peers their grant covers eagerly,
// instead of the first request waiting for the connection to be established.
lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled")
if err != nil {
return options, err
}
options.LazyConnectionEnabled = lazyConnectionEnabled
return options, nil
}
// boolOption reads a boolean option, returning nil when the caller left it out.
// js.Value.Bool panics on any other type, so a wrong type is reported instead.
func boolOption(jsOptions js.Value, name string) (*bool, error) {
v := jsOptions.Get(name)
if v.IsNull() || v.IsUndefined() {
return nil, nil
}
if v.Type() != js.TypeBoolean {
return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type())
}
b := v.Bool()
return &b, nil
}
// createStartMethod creates the start method for the client
func createStartMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) any {

View File

@@ -0,0 +1,64 @@
//go:build js
package main
import (
"syscall/js"
"testing"
)
// TestParseClientOptionsBooleans covers the boolean options against the value
// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean,
// so a wrong type has to be rejected before it reaches the client.
func TestParseClientOptionsBooleans(t *testing.T) {
t.Run("unset leaves the lazy override empty", func(t *testing.T) {
options, err := parseClientOptions(js.Global().Get("Object").New())
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled != nil {
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
}
if options.DisableIPv6 {
t.Error("disableIPv6 should default to false")
}
})
t.Run("null defers to the management flag", func(t *testing.T) {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", js.Null())
options, err := parseClientOptions(jsOptions)
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled != nil {
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
}
})
t.Run("booleans are carried through", func(t *testing.T) {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", false)
jsOptions.Set("disableIPv6", true)
options, err := parseClientOptions(jsOptions)
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled {
t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled)
}
if !options.DisableIPv6 {
t.Error("disableIPv6 should be true")
}
})
t.Run("a non-boolean is rejected", func(t *testing.T) {
for _, value := range []any{"true", 1, js.Global().Get("Object").New()} {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", value)
if _, err := parseClientOptions(jsOptions); err == nil {
t.Errorf("value %v should be rejected", value)
}
}
})
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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".

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}