Compare commits

...

6 Commits

Author SHA1 Message Date
jnfrati
8e05834384 [infra] Unify self-hosted deployment paths in getting-started.sh
Make getting-started.sh the single entry point for all self-hosted
deployments. The wizard now asks two independent questions - identity
provider (built-in vs standalone OIDC) and architecture (combined
netbird-server vs separate management/signal/relay containers) - and
renders the matching Docker Compose deployment with full reverse-proxy
parity (built-in Traefik, external Traefik, Nginx, NPM, Caddy, manual).

Highlights:
- setup.env contract: every wizard answer is persisted; --non-interactive
  re-renders idempotently from the file (IaC), --render-only generates
  without starting services. Secrets are generated once and appended so
  re-renders never rotate them.
- Split architecture renders a modern management.json (embedded Dex or
  external OIDC via PKCE), drops coturn entirely (the relay container
  serves STUN via NB_ENABLE_STUN), and optionally adds a PostgreSQL
  container when the postgres engine is selected without a DSN.
- The standalone-IdP path is framed around its real differentiator:
  multi-account support. The built-in IdP supports external SSO
  connectors but enforces single account mode.
- configure.sh, getting-started-with-dex.sh and
  getting-started-with-zitadel.sh print deprecation banners; their
  templates are frozen pending removal.
- New tests/test-render.sh validates all 8 combos (JSON validity,
  compose config, idempotent re-render, combined+external rejection)
  and runs in CI as the test-render-matrix job.
2026-06-11 13:34:30 +02:00
Philip Laine
079bce3c2f Add commands to discover and write Kubernetes configuration (#6260) 2026-06-10 15:00:10 +02:00
Maycon Santos
1a09aa6715 [misc] Update Go toolchain version in go.mod (#6377) 2026-06-10 14:50:57 +02:00
Maycon Santos
61abf5b9ea [proxy] Use UUID for proxy ID generation (#6391)
Use UUID for proxy ID instead of the second to avoid race conditions when running multiple nodes at the same time.
2026-06-10 13:35:26 +02:00
Boris Dolgov
e229050ba3 [proxy] Notify certificate ready for domains covered by the static certificate (#6389) 2026-06-10 12:05:34 +02:00
Zoltan Papp
e919b2d55d [client] Preserve posture checks on config-only sync updates (#6373)
* [client] Preserve posture checks on config-only sync updates

When management sends a MessageTypeControlConfig update (e.g. relay token
rotation), the SyncResponse carries no NetworkMap and no Checks. Moving the
updateChecksIfNew call after the nm == nil guard ensures posture checks are
only updated when a full network map is present, preventing relay token
rotation from silently clearing the previously applied posture check state.

* [client] Clarify posture check update logic with explicit comment

* [client] Extract NetBird config and sync persistence into helpers

Move the NetbirdConfig handling block out of handleSync into
updateNetbirdConfig and the sync response persistence into
persistSyncResponse, mirroring updateChecksIfNew. This flattens
handleSync and makes the individual update steps unit-testable.
2026-06-10 11:43:24 +02:00
15 changed files with 2685 additions and 289 deletions

View File

@@ -16,6 +16,20 @@ concurrency:
cancel-in-progress: true
jobs:
test-render-matrix:
runs-on: ubuntu-latest
steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run getting-started.sh render tests
run: bash infrastructure_files/tests/test-render.sh
test-docker-compose:
runs-on: ubuntu-latest
strategy:

301
client/cmd/kubernetes.go Normal file
View File

@@ -0,0 +1,301 @@
package cmd
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"github.com/goccy/go-yaml"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/proto"
)
const (
KubernetesDNSSuffix = "netbird-kubeapi-proxy"
)
var kubernetesCmd = &cobra.Command{
Use: "kubernetes",
Short: "Kubernetes cluster commands.",
Long: "Kubernetes cluster commands.",
}
var kubernetesListCmd = &cobra.Command{
Use: "list",
RunE: kubernetesList,
Short: "List Kubernetes clusters.",
Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
}
var kubernetesWriteKubeconfigCmd = &cobra.Command{
Use: "write-kubeconfig",
RunE: kubernetesWriteKubeconfig,
Args: cobra.ExactArgs(1),
Short: "Write kubeconfig for a Kubernetes cluster.",
Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
}
func init() {
kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
}
func kubernetesList(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
return err
}
defer conn.Close()
client := proto.NewDaemonServiceClient(conn)
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
return err
}
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
if err != nil {
return err
}
if len(kcs) == 0 {
cmd.Println("No Kubernetes clusters available.")
return nil
}
cmd.Println("Available Kubernetes clusters:")
for _, k := range kcs {
cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
}
return nil
}
func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
kubeconfigPath, err := resolveKubeconfigPath(cmd)
if err != nil {
return err
}
conn, err := getClient(cmd)
if err != nil {
return err
}
defer conn.Close()
client := proto.NewDaemonServiceClient(conn)
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
return err
}
clusterName := args[0]
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
if err != nil {
return err
}
if len(kcs) == 0 {
return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
}
if len(kcs) > 1 {
return fmt.Errorf("too many Kubernetes clusters returned")
}
err = writeKubeconfig(kubeconfigPath, kcs[0])
if err != nil {
return err
}
return nil
}
type kubernetesCluster struct {
name string
url *url.URL
version string
}
func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
httpClient := &http.Client{
Transport: transport,
}
resolver := net.Resolver{
// Required so both DNS records are returned.
// https://github.com/golang/go/issues/17093
PreferGo: true,
}
kcs := []kubernetesCluster{}
attempted := map[string]struct{}{}
for _, peer := range peers {
fqdns, err := resolver.LookupAddr(ctx, peer.IP)
if err != nil {
return nil, err
}
for _, fqdn := range fqdns {
if _, ok := attempted[fqdn]; ok {
continue
}
attempted[fqdn] = struct{}{}
comps := strings.Split(fqdn, ".")
if len(comps) < 2 {
continue
}
if comps[1] != KubernetesDNSSuffix {
continue
}
if nameFilter != "" && nameFilter != comps[0] {
continue
}
clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
if err != nil {
log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
continue
}
kc := kubernetesCluster{
name: comps[0],
url: clusterURL,
version: clusterVersion,
}
if nameFilter != "" {
return []kubernetesCluster{kc}, nil
}
kcs = append(kcs, kc)
}
}
return kcs, nil
}
func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
clusterURL, err := url.Parse("https://" + fqdn)
if err != nil {
return nil, "", err
}
versionURL, err := clusterURL.Parse("/version")
if err != nil {
return nil, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
if err != nil {
return nil, "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
versionData := map[string]string{}
err = json.Unmarshal(b, &versionData)
if err != nil {
return nil, "", err
}
version, ok := versionData["gitVersion"]
if !ok {
return nil, "", errors.New("no version found in response")
}
return clusterURL, version, nil
}
func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
if cmd.Flags().Changed("kubeconfig") {
path, err := cmd.Flags().GetString("kubeconfig")
if err != nil {
return "", err
}
return path, nil
}
if env := os.Getenv("KUBECONFIG"); env != "" {
return env, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
return filepath.Join(home, ".kube", "config"), nil
}
func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
b, err := os.ReadFile(kubeconfigPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
var cfg map[string]any
if err := yaml.Unmarshal(b, &cfg); err != nil {
return err
}
if cfg == nil {
cfg = map[string]any{
"apiVersion": "v1",
"kind": "Config",
}
}
cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
"name": kc.name,
"cluster": map[string]any{
"server": kc.url.String(),
"insecure-skip-tls-verify": true,
},
})
cfg["users"] = appendWithName(cfg["users"], map[string]any{
"name": "netbird",
"user": map[string]any{
"token": "none",
},
})
cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
"name": kc.name,
"context": map[string]any{
"cluster": kc.name,
"user": "netbird",
"namespace": "default",
},
})
cfg["current-context"] = kc.name
out, err := yaml.Marshal(cfg)
if err != nil {
return err
}
if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
return err
}
return nil
}
func appendWithName(data any, add map[string]any) any {
if data == nil {
return []any{add}
}
v, ok := data.([]any)
if !ok {
return []any{add}
}
i := slices.IndexFunc(v, func(item any) bool {
m, ok := item.(map[string]any)
if !ok {
return false
}
return m["name"] == add["name"]
})
if i == -1 {
return append(v, add)
}
v[i] = add
return v
}

View File

@@ -0,0 +1,120 @@
package cmd
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
func TestFingerprintClusters(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint: errcheck
w.Write([]byte(`{"gitVersion": "foobar"}`))
}))
defer srv.Close()
clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
require.NoError(t, err)
require.Equal(t, srv.URL, clusterURL.String())
require.Equal(t, "foobar", clusterVersion)
}
func TestResolveKubeconfigPath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("could not determine home directory: %v", err)
}
defaultPath := filepath.Join(home, ".kube", "config")
path, err := resolveKubeconfigPath(&cobra.Command{})
require.NoError(t, err)
require.Equal(t, defaultPath, path)
flagPath := "flag-path"
cmd := &cobra.Command{}
cmd.Flags().String("kubeconfig", "", "")
err = cmd.Flags().Set("kubeconfig", flagPath)
require.NoError(t, err)
path, err = resolveKubeconfigPath(cmd)
require.NoError(t, err)
require.Equal(t, flagPath, path)
envPath := "env-path"
t.Setenv("KUBECONFIG", envPath)
path, err = resolveKubeconfigPath(&cobra.Command{})
require.NoError(t, err)
require.Equal(t, envPath, path)
}
func TestWriteKubeconfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
existing string
}{
{
name: "empty file",
},
{
name: "existing content",
existing: `apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://foobar.com
name: foo
current-context: test
kind: Config
users: []
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
kubeconfigPath := filepath.Join(t.TempDir(), "config")
err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
require.NoError(t, err)
kc := kubernetesCluster{
name: "foo",
url: &url.URL{Scheme: "https", Host: "example.com"},
}
err = writeKubeconfig(kubeconfigPath, kc)
require.NoError(t, err)
b, err := os.ReadFile(kubeconfigPath)
require.NoError(t, err)
expected := `apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://example.com
name: foo
contexts:
- context:
cluster: foo
namespace: default
user: netbird
name: foo
current-context: foo
kind: Config
users:
- name: netbird
user:
token: none
`
require.Equal(t, expected, string(b))
})
}
}

View File

@@ -169,6 +169,11 @@ func init() {
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
// kubernetes commands
rootCmd.AddCommand(kubernetesCmd)
kubernetesCmd.AddCommand(kubernetesListCmd)
kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
// profile commands
profileCmd.AddCommand(profileListCmd)
profileCmd.AddCommand(profileAddCmd)

View File

@@ -880,62 +880,25 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
if update.GetNetbirdConfig() != nil {
wCfg := update.GetNetbirdConfig()
err := e.updateTURNs(wCfg.GetTurns())
if err != nil {
return fmt.Errorf("update TURNs: %w", err)
}
if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
return err
}
err = e.updateSTUNs(wCfg.GetStuns())
if err != nil {
return fmt.Errorf("update STUNs: %w", err)
}
var stunTurn []*stun.URI
stunTurn = append(stunTurn, e.STUNs...)
stunTurn = append(stunTurn, e.TURNs...)
e.stunTurn.Store(stunTurn)
err = e.handleRelayUpdate(wCfg.GetRelay())
if err != nil {
return err
}
err = e.handleFlowUpdate(wCfg.GetFlow())
if err != nil {
return fmt.Errorf("handle the flow configuration: %w", err)
}
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
log.Warnf("Failed to update DNS server config: %v", err)
}
// todo update signal
// Posture checks are bound to the network map presence:
// NetworkMap != nil, checks present -> apply the received checks
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
// leave the previously applied checks untouched
nm := update.GetNetworkMap()
if nm == nil {
return nil
}
if err := e.updateChecksIfNew(update.Checks); err != nil {
return err
}
nm := update.GetNetworkMap()
if nm == nil {
return nil
}
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
// A non-nil syncStore is what marks persistence as enabled. Hold the lock for
// the whole Set so the store cannot be cleared (disabled / engine close)
// mid-call and have this write resurrect a file that was just removed.
e.syncRespMux.RLock()
if e.syncStore != nil {
if err := e.syncStore.Set(update); err != nil {
log.Errorf("failed to persist sync response: %v", err)
} else {
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
}
e.syncRespMux.RUnlock()
e.persistSyncResponse(update)
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -947,6 +910,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// updateNetbirdConfig applies the management-provided NetBird configuration:
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
// which is the case for sync updates carrying only a network map.
func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
if wCfg == nil {
return nil
}
if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
return fmt.Errorf("update TURNs: %w", err)
}
if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
return fmt.Errorf("update STUNs: %w", err)
}
var stunTurn []*stun.URI
stunTurn = append(stunTurn, e.STUNs...)
stunTurn = append(stunTurn, e.TURNs...)
e.stunTurn.Store(stunTurn)
if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
return err
}
if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
return fmt.Errorf("handle the flow configuration: %w", err)
}
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
log.Warnf("Failed to update DNS server config: %v", err)
}
// todo update signal
return nil
}
// persistSyncResponse stores the full sync response so it can be restored on the next
// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
// engine close) mid-call and have this write resurrect a file that was just removed.
func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
e.syncRespMux.RLock()
defer e.syncRespMux.RUnlock()
if e.syncStore == nil {
return
}
if err := e.syncStore.Set(update); err != nil {
log.Errorf("failed to persist sync response: %v", err)
return
}
log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
}
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
if update != nil {
// when we receive token we expect valid address list too

6
go.mod
View File

@@ -2,6 +2,8 @@ module github.com/netbirdio/netbird
go 1.25.5
toolchain go1.25.11
require (
cunicu.li/go-rosenpass v0.5.42
github.com/cenkalti/backoff/v4 v4.3.0
@@ -54,6 +56,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/go-jose/go-jose/v4 v4.1.4
github.com/goccy/go-yaml v1.18.0
github.com/godbus/dbus/v5 v5.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang/mock v1.6.0
@@ -211,10 +214,9 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.4 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/s2a-go v0.1.9 // indirect

4
go.sum
View File

@@ -275,8 +275,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View File

@@ -1,6 +1,20 @@
#!/bin/bash
set -e
echo "**********************************************************************************"
echo "* DEPRECATION NOTICE *"
echo "* *"
echo "* configure.sh and its templates are deprecated and will be removed in a future *"
echo "* release. Use getting-started.sh instead, which now covers multi-container *"
echo "* deployments with your own OIDC provider: *"
echo "* *"
echo "* ./getting-started.sh # interactive wizard *"
echo "* ./getting-started.sh --non-interactive # render from setup.env (IaC) *"
echo "* *"
echo "* Migration guide: https://docs.netbird.io/selfhosted/selfhosted-guide *"
echo "**********************************************************************************"
echo ""
if ! which curl >/dev/null 2>&1; then
echo "This script uses curl fetch OpenID configuration from IDP."
echo "Please install curl and re-run the script https://curl.se/"

View File

@@ -5,6 +5,15 @@ set -e
# NetBird Getting Started with Dex IDP
# This script sets up NetBird with Dex as the identity provider
echo "**********************************************************************************"
echo "* DEPRECATION NOTICE *"
echo "* *"
echo "* getting-started-with-dex.sh is deprecated and will be removed in a future *"
echo "* release. The built-in identity provider IS Dex, embedded in the NetBird *"
echo "* server - no separate container needed. Use getting-started.sh instead. *"
echo "**********************************************************************************"
echo ""
# Sed pattern to strip base64 padding characters
SED_STRIP_PADDING='s/=//g'

View File

@@ -2,6 +2,17 @@
set -e
echo "**********************************************************************************"
echo "* DEPRECATION NOTICE *"
echo "* *"
echo "* getting-started-with-zitadel.sh is deprecated and will be removed in a future *"
echo "* release. Use getting-started.sh instead: pick the built-in IdP for the *"
echo "* simplest setup, or choose 'your own OIDC provider' to connect a Zitadel *"
echo "* instance you manage (see *"
echo "* https://docs.netbird.io/selfhosted/identity-providers/zitadel). *"
echo "**********************************************************************************"
echo ""
handle_request_command_status() {
PARSED_RESPONSE=$1
FUNCTION_NAME=$2

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +1,111 @@
## example file, you can copy this file to setup.env and update its values
##
# NetBird deployment configuration - example file
#
# Copy to setup.env, fill in the values, and render the deployment with:
# ./getting-started.sh --non-interactive
#
# Running ./getting-started.sh without flags starts an interactive wizard that
# writes this file for you. Re-rendering from the same file is idempotent, so
# it is safe to keep setup.env under configuration management (IaC).
#
# NOTE: the legacy configure.sh variable set (NETBIRD_TURN_DOMAIN, coturn,
# per-service ports, NETBIRD_DISABLE_LETSENCRYPT, ...) is deprecated together
# with configure.sh. See https://docs.netbird.io/selfhosted/selfhosted-guide
# Image tags
# you can force specific tags for each component; will be set to latest if empty
NETBIRD_DASHBOARD_TAG=""
NETBIRD_SIGNAL_TAG=""
NETBIRD_MANAGEMENT_TAG=""
COTURN_TAG=""
NETBIRD_RELAY_TAG=""
# Dashboard domain. e.g. app.mydomain.com
# Your NetBird domain, e.g. netbird.mydomain.com (required)
NETBIRD_DOMAIN=""
# TURN server domain. e.g. turn.mydomain.com
# if not specified it will assume NETBIRD_DOMAIN
NETBIRD_TURN_DOMAIN=""
# Deployment architecture:
# combined - single netbird-server container (management + signal + relay + STUN)
# split - separate management, signal and relay containers (for scale-out,
# required when using your own OIDC provider)
NETBIRD_ARCHITECTURE="combined"
# TURN server public IP address
# required for a connection involving peers in
# the same network as the server and external peers
# usually matches the IP for the domain set in NETBIRD_TURN_DOMAIN
NETBIRD_TURN_EXTERNAL_IP=""
# Identity provider:
# embedded - built-in IdP with local users (simplest)
# external - your own OIDC provider (requires NETBIRD_ARCHITECTURE=split)
NETBIRD_IDP_MODE="embedded"
# -------------------------------------------
# OIDC
# e.g., https://example.eu.auth0.com/.well-known/openid-configuration
# External OIDC provider (NETBIRD_IDP_MODE=external)
# See https://docs.netbird.io/selfhosted/identity-providers
# -------------------------------------------
# e.g. https://keycloak.example.com/realms/netbird/.well-known/openid-configuration
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT=""
# The default setting is to transmit the audience to the IDP during authorization. However,
# if your IDP does not have this capability, you can turn this off by setting it to false.
#NETBIRD_DASH_AUTH_USE_AUDIENCE=false
NETBIRD_AUTH_AUDIENCE=""
# e.g. netbird-client
# OAuth client ID registered for NetBird, e.g. netbird
NETBIRD_AUTH_CLIENT_ID=""
# indicates the scopes that will be requested to the IDP
NETBIRD_AUTH_SUPPORTED_SCOPES=""
# NETBIRD_AUTH_CLIENT_SECRET is required only by Google workspace.
# NETBIRD_AUTH_CLIENT_SECRET=""
# if you want to use a custom claim for the user ID instead of 'sub', set it here
# NETBIRD_AUTH_USER_ID_CLAIM=""
# indicates whether to use Auth0 or not: true or false
# Client secret, only if your IdP requires one (e.g. Google Workspace)
NETBIRD_AUTH_CLIENT_SECRET=""
# JWT audience; defaults to the client ID when empty
NETBIRD_AUTH_AUDIENCE=""
# Scopes requested from the IdP
NETBIRD_AUTH_SUPPORTED_SCOPES="openid profile email"
# Set to true only for Auth0
NETBIRD_USE_AUTH0="false"
# if your IDP provider doesn't support fragmented URIs, configure custom
# redirect and silent redirect URIs, these will be concatenated into your NETBIRD_DOMAIN domain.
# NETBIRD_AUTH_REDIRECT_URI="/peers"
# NETBIRD_AUTH_SILENT_REDIRECT_URI="/add-peers"
# Updates the preference to use id tokens instead of access token on dashboard
# Okta and Gitlab IDPs can benefit from this
# NETBIRD_TOKEN_SOURCE="idToken"
# -------------------------------------------
# OIDC Device Authorization Flow
# -------------------------------------------
NETBIRD_AUTH_DEVICE_AUTH_PROVIDER="none"
NETBIRD_AUTH_DEVICE_AUTH_CLIENT_ID=""
# Some IDPs requires different audience, scopes and to use id token for device authorization flow
# you can customize here:
NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE=$NETBIRD_AUTH_AUDIENCE
NETBIRD_AUTH_DEVICE_AUTH_SCOPE="openid"
NETBIRD_AUTH_DEVICE_AUTH_USE_ID_TOKEN=false
# -------------------------------------------
# OIDC PKCE Authorization Flow
# -------------------------------------------
# Comma separated port numbers. if already in use, PKCE flow will choose an available port from the list as an alternative
# eg. 53000,54000
NETBIRD_AUTH_PKCE_REDIRECT_URL_PORTS="53000"
# -------------------------------------------
# IDP Management
# -------------------------------------------
# Token the dashboard sends to the management API: accessToken or idToken
# (Okta and GitLab benefit from idToken)
NETBIRD_TOKEN_SOURCE="accessToken"
# Device authorization flow for headless clients (optional)
#NETBIRD_AUTH_DEVICE_AUTH_CLIENT_ID=""
# IdP management API for user/group sync (optional)
# eg. zitadel, auth0, azure, keycloak
NETBIRD_MGMT_IDP="none"
# Some IDPs requires different client id and client secret for management api
NETBIRD_IDP_MGMT_CLIENT_ID=$NETBIRD_AUTH_CLIENT_ID
NETBIRD_IDP_MGMT_CLIENT_SECRET=""
# Required when setting up with Keycloak "https://<YOUR_KEYCLOAK_HOST_AND_PORT>/admin/realms/netbird"
# NETBIRD_IDP_MGMT_EXTRA_ADMIN_ENDPOINT=
# With some IDPs may be needed enabling automatic refresh of signing keys on expire
# NETBIRD_MGMT_IDP_SIGNKEY_REFRESH=false
# NETBIRD_IDP_MGMT_EXTRA_ variables. See https://docs.netbird.io/selfhosted/identity-providers for more information about your IDP of choice.
# -------------------------------------------
# Letsencrypt
# -------------------------------------------
# Disable letsencrypt
# if disabled, cannot use HTTPS anymore and requires setting up a reverse-proxy to do it instead
NETBIRD_DISABLE_LETSENCRYPT=false
# e.g. hello@mydomain.com
NETBIRD_LETSENCRYPT_EMAIL=""
# -------------------------------------------
# Extra settings
# -------------------------------------------
# Disable anonymous metrics collection, see more information at https://netbird.io/docs/FAQ/metrics-collection
NETBIRD_DISABLE_ANONYMOUS_METRICS=false
# DNS DOMAIN configures the domain name used for peer resolution. By default it is netbird.selfhosted
NETBIRD_MGMT_DNS_DOMAIN=netbird.selfhosted
# Disable default all-to-all policy for new accounts
NETBIRD_MGMT_DISABLE_DEFAULT_POLICY=false
# -------------------------------------------
# Relay settings
# -------------------------------------------
# Relay server domain. e.g. relay.mydomain.com
# if not specified it will assume NETBIRD_DOMAIN
NETBIRD_RELAY_DOMAIN=""
#NETBIRD_MGMT_IDP=""
#NETBIRD_IDP_MGMT_CLIENT_ID=""
#NETBIRD_IDP_MGMT_CLIENT_SECRET=""
# Relay server connection port. If none is supplied
# it will default to 33080
# should be updated to match TLS-port of reverse proxy when netbird is running behind reverse proxy
NETBIRD_RELAY_PORT=""
# -------------------------------------------
# Reverse proxy
# -------------------------------------------
# 0 = built-in Traefik (automatic TLS via Let's Encrypt)
# 1 = existing Traefik instance (labels)
# 2 = Nginx (config template is generated)
# 3 = Nginx Proxy Manager (advanced config is generated)
# 4 = external Caddy (Caddyfile snippet is generated)
# 5 = other/manual
NETBIRD_REVERSE_PROXY_TYPE="0"
# Let's Encrypt notification email (required for type 0)
NETBIRD_TRAEFIK_ACME_EMAIL=""
# Settings for an existing Traefik instance (type 1)
NETBIRD_TRAEFIK_EXTERNAL_NETWORK=""
NETBIRD_TRAEFIK_ENTRYPOINT="websecure"
NETBIRD_TRAEFIK_CERTRESOLVER=""
# Bind container ports to 127.0.0.1 only (types 2-5)
NETBIRD_BIND_LOCALHOST_ONLY="true"
# Docker network of your reverse proxy, if it runs in Docker (types 2-4)
NETBIRD_EXTERNAL_PROXY_NETWORK=""
# Management API connecting port. If none is supplied
# it will default to 33073
# should be updated to match TLS-port of reverse proxy when netbird is running behind reverse proxy
NETBIRD_MGMT_API_PORT=""
# -------------------------------------------
# NetBird Proxy and CrowdSec
# (combined architecture with built-in Traefik only)
# -------------------------------------------
NETBIRD_ENABLE_PROXY="false"
NETBIRD_ENABLE_CROWDSEC="false"
# Signal service connecting port. If none is supplied
# it will default to 10000
# should be updated to match TLS-port of reverse proxy when netbird is running behind reverse proxy
NETBIRD_SIGNAL_PORT=""
# -------------------------------------------
# Datastore
# -------------------------------------------
# sqlite or postgres. With postgres and an empty DSN a PostgreSQL container is
# added to the deployment (split architecture only).
NETBIRD_STORE_CONFIG_ENGINE="sqlite"
NETBIRD_STORE_ENGINE_POSTGRES_DSN=""
NETBIRD_POSTGRES_PASSWORD=""
# -------------------------------------------
# Secrets
# Generated automatically on first run and appended to setup.env.
# Keep them stable across re-renders or peers will lose connectivity.
# -------------------------------------------
NETBIRD_RELAY_AUTH_SECRET=""
NETBIRD_DATASTORE_ENC_KEY=""
# -------------------------------------------
# Docker image overrides (optional)
# -------------------------------------------
#DASHBOARD_IMAGE="netbirdio/dashboard:latest"
#NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
#MANAGEMENT_IMAGE="netbirdio/management:latest"
#SIGNAL_IMAGE="netbirdio/signal:latest"
#RELAY_IMAGE="netbirdio/relay:latest"
#POSTGRES_IMAGE="postgres:16-alpine"
#TRAEFIK_IMAGE="traefik:v3.6"

View File

@@ -0,0 +1,196 @@
#!/bin/bash
# Render-validation tests for getting-started.sh.
# Runs the script in --non-interactive --render-only mode for every supported
# architecture / IdP / reverse-proxy combination and validates the generated
# files. Requires jq; uses `docker compose config` when docker is available.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GETTING_STARTED="$SCRIPT_DIR/../getting-started.sh"
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
FAILURES=0
compose_validate() {
if docker compose version &>/dev/null; then
docker compose -f "$1" config -q
return $?
fi
if command -v docker-compose &>/dev/null; then
docker-compose -f "$1" config -q
return $?
fi
# Without docker fall back to a structural sanity check
grep -q "^services:" "$1"
return $?
}
write_oidc_fixture() {
# A file:// OpenID discovery document stands in for a real IdP
cat > "$1" <<EOF
{
"issuer": "https://idp.example.org/realms/netbird",
"authorization_endpoint": "https://idp.example.org/realms/netbird/protocol/openid-connect/auth",
"token_endpoint": "https://idp.example.org/realms/netbird/protocol/openid-connect/token",
"jwks_uri": "https://idp.example.org/realms/netbird/protocol/openid-connect/certs",
"device_authorization_endpoint": "https://idp.example.org/realms/netbird/protocol/openid-connect/auth/device"
}
EOF
}
run_case() {
local name="$1"
shift
local expected_files=("$@")
local case_dir="$WORK_DIR/$name"
mkdir -p "$case_dir"
# setup.env content is provided on stdin
cat > "$case_dir/setup.env"
echo "--- case: $name"
if ! (cd "$case_dir" && bash "$GETTING_STARTED" --non-interactive --render-only > render.log 2>&1); then
echo "FAIL($name): render exited non-zero"
tail -5 "$case_dir/render.log" | sed 's/^/ /'
FAILURES=$((FAILURES + 1))
return 0
fi
for f in "${expected_files[@]}"; do
if [[ ! -f "$case_dir/$f" ]]; then
echo "FAIL($name): expected file $f was not generated"
FAILURES=$((FAILURES + 1))
fi
done
if [[ -f "$case_dir/management.json" ]] && ! jq . "$case_dir/management.json" > /dev/null; then
echo "FAIL($name): management.json is not valid JSON"
FAILURES=$((FAILURES + 1))
fi
if [[ -f "$case_dir/docker-compose.yml" ]] && ! compose_validate "$case_dir/docker-compose.yml"; then
echo "FAIL($name): docker-compose.yml failed validation"
FAILURES=$((FAILURES + 1))
fi
# Re-rendering from the persisted setup.env must be byte-identical (idempotent)
local checksums_before checksums_after
checksums_before=$(cd "$case_dir" && sha256sum docker-compose.yml dashboard.env config.yaml management.json 2>/dev/null || true)
if ! (cd "$case_dir" && bash "$GETTING_STARTED" --non-interactive --render-only > render2.log 2>&1); then
echo "FAIL($name): re-render exited non-zero"
FAILURES=$((FAILURES + 1))
return 0
fi
checksums_after=$(cd "$case_dir" && sha256sum docker-compose.yml dashboard.env config.yaml management.json 2>/dev/null || true)
if [[ "$checksums_before" != "$checksums_after" ]]; then
echo "FAIL($name): re-render from setup.env is not idempotent"
FAILURES=$((FAILURES + 1))
fi
return 0
}
OIDC_FIXTURE="$WORK_DIR/openid-configuration-fixture.json"
write_oidc_fixture "$OIDC_FIXTURE"
run_case combined-embedded-traefik docker-compose.yml config.yaml dashboard.env <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="combined"
NETBIRD_IDP_MODE="embedded"
NETBIRD_REVERSE_PROXY_TYPE="0"
NETBIRD_TRAEFIK_ACME_EMAIL="admin@example.org"
EOF
run_case combined-embedded-nginx docker-compose.yml config.yaml dashboard.env nginx-netbird.conf <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="combined"
NETBIRD_IDP_MODE="embedded"
NETBIRD_REVERSE_PROXY_TYPE="2"
EOF
run_case split-embedded-traefik docker-compose.yml management.json dashboard.env <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="split"
NETBIRD_IDP_MODE="embedded"
NETBIRD_REVERSE_PROXY_TYPE="0"
NETBIRD_TRAEFIK_ACME_EMAIL="admin@example.org"
EOF
run_case split-embedded-external-traefik docker-compose.yml management.json dashboard.env <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="split"
NETBIRD_IDP_MODE="embedded"
NETBIRD_REVERSE_PROXY_TYPE="1"
NETBIRD_TRAEFIK_ENTRYPOINT="websecure"
NETBIRD_TRAEFIK_CERTRESOLVER="letsencrypt"
EOF
run_case split-external-nginx-postgres docker-compose.yml management.json dashboard.env nginx-netbird.conf <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="split"
NETBIRD_IDP_MODE="external"
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT="file://$OIDC_FIXTURE"
NETBIRD_AUTH_CLIENT_ID="netbird"
NETBIRD_AUTH_CLIENT_SECRET="some-secret"
NETBIRD_REVERSE_PROXY_TYPE="2"
NETBIRD_STORE_CONFIG_ENGINE="postgres"
EOF
run_case split-external-manual docker-compose.yml management.json dashboard.env <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="split"
NETBIRD_IDP_MODE="external"
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT="file://$OIDC_FIXTURE"
NETBIRD_AUTH_CLIENT_ID="netbird"
NETBIRD_REVERSE_PROXY_TYPE="5"
NETBIRD_BIND_LOCALHOST_ONLY="false"
EOF
# Auto-forced split must be accepted when no architecture is given with external IdP
run_case split-external-defaulted docker-compose.yml management.json dashboard.env <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_IDP_MODE="external"
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT="file://$OIDC_FIXTURE"
NETBIRD_AUTH_CLIENT_ID="netbird"
NETBIRD_REVERSE_PROXY_TYPE="5"
EOF
# Invalid combination must fail: combined + external IdP
echo "--- case: combined-external-rejected"
REJECT_DIR="$WORK_DIR/combined-external-rejected"
mkdir -p "$REJECT_DIR"
cat > "$REJECT_DIR/setup.env" <<EOF
NETBIRD_DOMAIN="netbird.example.org"
NETBIRD_ARCHITECTURE="combined"
NETBIRD_IDP_MODE="external"
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT="file://$OIDC_FIXTURE"
NETBIRD_AUTH_CLIENT_ID="netbird"
NETBIRD_REVERSE_PROXY_TYPE="5"
EOF
if (cd "$REJECT_DIR" && bash "$GETTING_STARTED" --non-interactive --render-only > render.log 2>&1); then
echo "FAIL(combined-external-rejected): combined + external IdP was not rejected"
FAILURES=$((FAILURES + 1))
fi
# Spot-check rendered content
SPLIT_EXT="$WORK_DIR/split-external-nginx-postgres"
if [[ -f "$SPLIT_EXT/management.json" ]]; then
[[ $(jq -r '.HttpConfig.AuthIssuer' "$SPLIT_EXT/management.json") == "https://idp.example.org/realms/netbird" ]] || { echo "FAIL: external AuthIssuer mismatch"; FAILURES=$((FAILURES + 1)); }
[[ $(jq -r '.EmbeddedIdP' "$SPLIT_EXT/management.json") == "null" ]] || { echo "FAIL: external mode must not configure EmbeddedIdP"; FAILURES=$((FAILURES + 1)); }
[[ $(jq -r '.StoreConfig.Engine' "$SPLIT_EXT/management.json") == "postgres" ]] || { echo "FAIL: postgres engine not set"; FAILURES=$((FAILURES + 1)); }
grep -q "postgres:" "$SPLIT_EXT/docker-compose.yml" || { echo "FAIL: postgres container missing"; FAILURES=$((FAILURES + 1)); }
fi
SPLIT_EMB="$WORK_DIR/split-embedded-traefik"
if [[ -f "$SPLIT_EMB/management.json" ]]; then
[[ $(jq -r '.EmbeddedIdP.Enabled' "$SPLIT_EMB/management.json") == "true" ]] || { echo "FAIL: embedded IdP not enabled in split mode"; FAILURES=$((FAILURES + 1)); }
[[ $(jq -r '.Relay.Addresses[0]' "$SPLIT_EMB/management.json") == "rels://netbird.example.org:443" ]] || { echo "FAIL: relay address mismatch"; FAILURES=$((FAILURES + 1)); }
fi
echo ""
if [[ $FAILURES -gt 0 ]]; then
echo "$FAILURES test(s) failed"
exit 1
fi
echo "All render tests passed"

View File

@@ -24,6 +24,7 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/google/uuid"
"github.com/pires/go-proxyproto"
prometheus2 "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -75,29 +76,30 @@ type portRouter struct {
}
type Server struct {
ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
acme *acme.Manager
auth *auth.Middleware
http *http.Server
https *http.Server
debug *http.Server
healthServer *health.Server
healthChecker *health.Checker
meter *proxymetrics.Metrics
accessLog *accesslog.Logger
mainRouter *nbtcp.Router
mainPort uint16
udpMu sync.Mutex
udpRelays map[types.ServiceID]*udprelay.Relay
udpRelayWg sync.WaitGroup
portMu sync.RWMutex
portRouters map[uint16]*portRouter
svcPorts map[types.ServiceID][]uint16
lastMappings map[types.ServiceID]*proto.ProxyMapping
portRouterWg sync.WaitGroup
ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
acme *acme.Manager
staticCertWatcher *certwatch.Watcher
auth *auth.Middleware
http *http.Server
https *http.Server
debug *http.Server
healthServer *health.Server
healthChecker *health.Checker
meter *proxymetrics.Metrics
accessLog *accesslog.Logger
mainRouter *nbtcp.Router
mainPort uint16
udpMu sync.Mutex
udpRelays map[types.ServiceID]*udprelay.Relay
udpRelayWg sync.WaitGroup
portMu sync.RWMutex
portRouters map[uint16]*portRouter
svcPorts map[types.ServiceID][]uint16
lastMappings map[types.ServiceID]*proto.ProxyMapping
portRouterWg sync.WaitGroup
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
// so they can be closed during graceful shutdown, since http.Server.Shutdown
@@ -614,7 +616,7 @@ func (s *Server) initDefaults() {
// If no ID is set then one can be generated.
if s.ID == "" {
s.ID = "netbird-proxy-" + s.startTime.Format("20060102150405")
s.ID = fmt.Sprintf("netbird-proxy-%s", uuid.NewString())
}
// Fallback version option in case it is not set.
if s.Version == "" {
@@ -792,6 +794,7 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
return nil, fmt.Errorf("initialize certificate watcher: %w", err)
}
go certWatcher.Watch(ctx)
s.staticCertWatcher = certWatcher
tlsConfig.GetCertificate = certWatcher.GetCertificate
return tlsConfig, nil
}
@@ -1623,6 +1626,8 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
var wildcardHit bool
if s.acme != nil {
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
} else {
wildcardHit = s.staticCertCovers(d)
}
httpRoute := nbtcp.Route{
Type: nbtcp.RouteHTTP,
@@ -1647,6 +1652,26 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
return nil
}
// staticCertCovers reports whether the static certificate loaded when ACME is
// disabled covers the given domain, making it certificate-ready immediately —
// the equivalent of a wildcard hit in the ACME path. Domains the certificate
// does not cover are logged: clients connecting to them will get TLS errors.
func (s *Server) staticCertCovers(d domain.Domain) bool {
if s.staticCertWatcher == nil {
return false
}
leaf := s.staticCertWatcher.Leaf()
if leaf == nil {
return false
}
name := d.PunycodeString()
if err := leaf.VerifyHostname(name); err != nil {
s.Logger.Warnf("static certificate (SANs %v) does not cover domain %q: %v", leaf.DNSNames, name, err)
return false
}
return true
}
// setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port.
func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
svcID := types.ServiceID(mapping.GetId())

89
proxy/static_cert_test.go Normal file
View File

@@ -0,0 +1,89 @@
package proxy
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/certwatch"
"github.com/netbirdio/netbird/shared/management/domain"
)
func generateCertWithSANs(t *testing.T, dnsNames []string) (certPEM, keyPEM []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: dnsNames[0]},
DNSNames: dnsNames,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
func newStaticWatcher(t *testing.T, dnsNames []string) *certwatch.Watcher {
t.Helper()
dir := t.TempDir()
certPEM, keyPEM := generateCertWithSANs(t, dnsNames)
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
require.NoError(t, os.WriteFile(certPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
w, err := certwatch.NewWatcher(certPath, keyPath, quietLifecycleLogger())
require.NoError(t, err)
return w
}
func TestStaticCertCovers(t *testing.T) {
s := &Server{
Logger: quietLifecycleLogger(),
staticCertWatcher: newStaticWatcher(t, []string{"*.p.example.com", "exact.example.com"}),
}
cases := []struct {
domain string
covered bool
}{
{"svc.p.example.com", true},
{"exact.example.com", true},
{"a.b.p.example.com", false}, // wildcard does not span labels
{"p.example.com", false},
{"other.example.com", false},
}
for _, tc := range cases {
t.Run(tc.domain, func(t *testing.T) {
assert.Equal(t, tc.covered, s.staticCertCovers(domain.Domain(tc.domain)))
})
}
}
func TestStaticCertCoversNoWatcher(t *testing.T) {
s := &Server{Logger: quietLifecycleLogger()}
assert.False(t, s.staticCertCovers(domain.Domain("svc.p.example.com")))
}