mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-11 18:39:59 +00:00
Compare commits
6 Commits
peer-acl-m
...
feat/getti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e05834384 | ||
|
|
079bce3c2f | ||
|
|
1a09aa6715 | ||
|
|
61abf5b9ea | ||
|
|
e229050ba3 | ||
|
|
e919b2d55d |
14
.github/workflows/test-infrastructure-files.yml
vendored
14
.github/workflows/test-infrastructure-files.yml
vendored
@@ -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
301
client/cmd/kubernetes.go
Normal 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
|
||||
}
|
||||
120
client/cmd/kubernetes_test.go
Normal file
120
client/cmd/kubernetes_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
6
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
196
infrastructure_files/tests/test-render.sh
Executable file
196
infrastructure_files/tests/test-render.sh
Executable 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"
|
||||
@@ -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
89
proxy/static_cert_test.go
Normal 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")))
|
||||
}
|
||||
Reference in New Issue
Block a user