mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 06:41:28 +02:00
Compare commits
8 Commits
feat/agent
...
fix/linux-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5fa2e7c94 | ||
|
|
eb8be082cd | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d | ||
|
|
2cd1f824a9 | ||
|
|
7546e7751c | ||
|
|
075b319fb3 | ||
|
|
b82a42c855 |
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
const allowedTags = [
|
||||
'management',
|
||||
'client',
|
||||
'android',
|
||||
'ios',
|
||||
'signal',
|
||||
'proxy',
|
||||
'relay',
|
||||
|
||||
@@ -57,6 +57,12 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -76,6 +82,8 @@ type Client struct {
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||
cfgPath string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
@@ -96,11 +104,12 @@ type Client struct {
|
||||
extendCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
c.config = cfg
|
||||
c.cacheDir = cacheDir
|
||||
c.cfgPath = cfgPath
|
||||
c.connectClient = cc
|
||||
}
|
||||
|
||||
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
||||
return c.config, c.cacheDir, c.connectClient
|
||||
}
|
||||
|
||||
// authSnapshot returns the config together with the path it was loaded from, in
|
||||
// one lock: the path identifies the profile whose account email backs the login
|
||||
// hint, so reading it separately could pair one profile's config with another's
|
||||
// hint when a profile switch lands in between.
|
||||
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
return c.config, c.cfgPath, c.connectClient
|
||||
}
|
||||
|
||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
|
||||
auth := NewAuthWithConfig(ctx, cfg)
|
||||
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||
err = auth.login(urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -170,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -211,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
|
||||
return e.RenewTun(fd)
|
||||
}
|
||||
|
||||
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return nil, fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
routes, searchDomains := e.TunSettings()
|
||||
return &TunSettings{
|
||||
Routes: strings.Join(routes, ";"),
|
||||
SearchDomains: strings.Join(searchDomains, ";"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -36,12 +38,20 @@ type Auth struct {
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
//
|
||||
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||
// management stream rejects it with "no peer auth method provided".
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
||||
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||
// file the config was loaded from; it identifies the profile whose account email
|
||||
// backs the login_hint.
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
cfgPath: cfgPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
||||
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
||||
// way internal/auth wires it in authenticateWithPKCEFlow.
|
||||
type loginHintSetter interface {
|
||||
SetLoginHint(hint string)
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||
// leaves the choice to the IdP, which is how accounts get switched.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
51
client/android/login_test.go
Normal file
51
client/android/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||
//
|
||||
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("first NewAuth: %v", err)
|
||||
}
|
||||
if first.config.PrivateKey == "" {
|
||||
t.Fatal("first NewAuth produced no private key")
|
||||
}
|
||||
|
||||
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("second NewAuth: %v", err)
|
||||
}
|
||||
|
||||
if second.config.PrivateKey != first.config.PrivateKey {
|
||||
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuth: %v", err)
|
||||
}
|
||||
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||
t.Fatal("NewAuth did not create a usable configuration")
|
||||
}
|
||||
if auth.cfgPath != cfgPath {
|
||||
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login or was logged out. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||
// an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(configPath)
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
108
client/android/profile_state.go
Normal file
108
client/android/profile_state.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on Android both resolve under files/, so sharing the name
|
||||
// would have the two overwrite each other — the state manager rewrites the
|
||||
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||
// this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
func readProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// writeProfileEmail records the account email for the profile whose config lives
|
||||
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||
// email is ignored rather than blanking what is already stored.
|
||||
func writeProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on logout: while the
|
||||
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||
// straight back into the account just logged out of. Mirrors the desktop UI's
|
||||
// RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||
want: "/data/data/io.netbird.client/files/netbird.account.json",
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json",
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
|
||||
want: "/data/data/io.netbird.client/files/profiles/work.account.json",
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on Android both
|
||||
// resolve under files/, and the state manager rewrites the whole file from its
|
||||
// own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after logout, got %q", got)
|
||||
}
|
||||
|
||||
// Logout may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
a := &Auth{ctx: ctx, config: cfg}
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
|
||||
@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
dnsServer, err := e.newDnsServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dns server: %w", err)
|
||||
}
|
||||
@@ -595,10 +590,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
WGInterface: e.wgInterface,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||
@@ -2102,42 +2095,6 @@ func (e *Engine) close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
if runtime.GOOS != "android" {
|
||||
// nolint:nilnil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
info := system.GetInfo(e.ctx)
|
||||
info.SetFlags(
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
e.config.DisableFirewall,
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
)
|
||||
|
||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
routes := toRoutes(netMap.GetRoutes())
|
||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
case "ios":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
// due to tests where we are using a mocked version of the DNS server
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
*dnsConfig,
|
||||
nbdns.Config{},
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
e.config.DisableDNS,
|
||||
|
||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
func (e *Engine) TunSettings() ([]string, []string) {
|
||||
e.syncMsgMux.Lock()
|
||||
routeManager := e.routeManager
|
||||
dnsServer := e.dnsServer
|
||||
e.syncMsgMux.Unlock()
|
||||
|
||||
var routes []string
|
||||
if routeManager != nil {
|
||||
routes = routeManager.CurrentRouteRange()
|
||||
}
|
||||
|
||||
var searchDomains []string
|
||||
if dnsServer != nil {
|
||||
searchDomains = dnsServer.SearchDomains()
|
||||
}
|
||||
|
||||
return routes, searchDomains
|
||||
}
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -62,7 +61,7 @@ type Manager interface {
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
CurrentRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
||||
WGInterface iface.WGIface
|
||||
StatusRecorder *peer.Status
|
||||
RelayManager *relayClient.Manager
|
||||
InitialRoutes []*route.Route
|
||||
StateManager *statemanager.Manager
|
||||
DNSServer dns.Server
|
||||
DNSFeatureFlag bool
|
||||
PeerStore *peerstore.Store
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -149,50 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||
dm.setupRefCounters(useNoop)
|
||||
|
||||
// don't proceed with client routes if it is disabled
|
||||
if config.DisableClientRoutes {
|
||||
return dm
|
||||
}
|
||||
|
||||
if runtime.GOOS == "android" {
|
||||
dm.setupAndroidRoutes(config)
|
||||
}
|
||||
return dm
|
||||
}
|
||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
||||
|
||||
routesForComparison := slices.Clone(cr)
|
||||
|
||||
if config.DNSFeatureFlag {
|
||||
cr = append(cr, m.enableFakeIPRoutes()...)
|
||||
}
|
||||
|
||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) enableFakeIPRoutes() []*route.Route {
|
||||
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
|
||||
v4ID := uuid.NewString()
|
||||
fakeIPRoute := &route.Route{
|
||||
ID: route.ID(v4ID),
|
||||
Network: m.fakeIPManager.GetFakeIPBlock(),
|
||||
NetID: route.NetID(v4ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv4Network,
|
||||
}
|
||||
v6ID := uuid.NewString()
|
||||
fakeIPv6Route := &route.Route{
|
||||
ID: route.ID(v6ID),
|
||||
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
||||
NetID: route.NetID(v6ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv6Network,
|
||||
}
|
||||
fakeRoutes := []*route.Route{fakeIPRoute, fakeIPv6Route}
|
||||
m.notifier.SetFakeIPRoutes(fakeRoutes)
|
||||
return fakeRoutes
|
||||
m.notifier.NotifyRouteChange()
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
@@ -508,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
||||
m.notifier.SetListener(listener)
|
||||
}
|
||||
|
||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
||||
func (m *DefaultManager) InitialRouteRange() []string {
|
||||
return m.notifier.GetInitialRouteRanges()
|
||||
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -708,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
||||
return newServerRoutesMap, newClientRoutesIDMap
|
||||
}
|
||||
|
||||
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
||||
_, crMap := m.ClassifyRoutes(initialRoutes)
|
||||
rs := make([]*route.Route, 0, len(crMap))
|
||||
for _, routes := range crMap {
|
||||
rs = append(rs, routes...)
|
||||
}
|
||||
|
||||
return rs
|
||||
}
|
||||
|
||||
func isRouteSupported(route *route.Route) bool {
|
||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
||||
func (m *MockManager) InitialRouteRange() []string {
|
||||
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||
func (m *MockManager) CurrentRouteRange() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -14,12 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
mu sync.Mutex
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
// currentRoutes is the last announced route set. It exists only to
|
||||
// suppress noise: without it every network map sync would trigger the
|
||||
// Java side, even when the routes did not change. The actual TUN route
|
||||
// state is owned by the route manager and pulled from there.
|
||||
currentRoutes []*route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
@@ -27,21 +29,15 @@ func NewNotifier() *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
|
||||
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
|
||||
n.fakeIPRoutes = routes
|
||||
n.notify()
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
@@ -55,44 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
return
|
||||
}
|
||||
|
||||
n.currentRoutes = newRoutes
|
||||
n.notify()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||
// Not used on Android
|
||||
}
|
||||
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
func (n *Notifier) notifyLocked() {
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
allRoutes = append(allRoutes, n.fakeIPRoutes...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
n.listener.OnNetworkChanged(strings.Join(routeStrings, ","))
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
@@ -100,20 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
return nets
|
||||
}
|
||||
|
||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := n.routesToStrings(a)
|
||||
bs := n.routesToStrings(b)
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
initialStrings := n.routesToStrings(n.initialRoutes)
|
||||
sort.Strings(initialStrings)
|
||||
return initialStrings
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -4,17 +4,26 @@ package main
|
||||
|
||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||
//
|
||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
||||
// fires the registered clickHandler:
|
||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
||||
// Registering OnClick here therefore covers both paths with one handler — no
|
||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
||||
// main window; right-click still opens the menu via Wails' default
|
||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
||||
// Expected behaviour per tray host:
|
||||
//
|
||||
// Host Left click Right click
|
||||
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||
// GNOME Shell + AppIndicator menu only menu only
|
||||
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||
//
|
||||
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||
//
|
||||
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
|
||||
// on ANY click and only reports the menu opening via dbusmenu
|
||||
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
|
||||
// both buttons raised the main window on top of the menu, and on KDE/Waybar
|
||||
// a right click raised it over the freshly opened menu. The netbirdio/wails
|
||||
// fork (go.mod replace) drops that heuristic: a menu open never fires
|
||||
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
|
||||
// entry; left-click-opens-window is not achievable there anyway, since the
|
||||
// host always opens the menu itself.
|
||||
//
|
||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||
|
||||
@@ -83,7 +83,6 @@ type ServerConfig struct {
|
||||
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
|
||||
type AgentNetworkConfig struct {
|
||||
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
|
||||
Zone string `yaml:"zone"`
|
||||
}
|
||||
|
||||
// TLSConfig contains TLS/HTTPS settings
|
||||
@@ -733,7 +732,6 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
||||
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
|
||||
AgentNetwork: nbconfig.AgentNetwork{
|
||||
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
|
||||
Zone: c.Server.AgentNetwork.Zone,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -147,11 +147,3 @@ server:
|
||||
# # is re-read periodically (mtime poll). An explicitly configured path that
|
||||
# # fails to load fails startup; runtime reload errors keep the previous table.
|
||||
# pricingDefaultsFile: "pricing.yaml"
|
||||
#
|
||||
# # Parent DNS zone that Agent Network gateway endpoints are allocated
|
||||
# # under, producing <subdomain>.<zone>. Empty (the default) preserves the
|
||||
# # legacy behaviour of deriving the endpoint from the serving cluster, so
|
||||
# # self-hosted deployments are unaffected. Captured onto each settings row
|
||||
# # when that row is created; changing it later does not move existing
|
||||
# # tenants.
|
||||
# zone: "gateway.example.com"
|
||||
|
||||
4
go.mod
4
go.mod
@@ -114,7 +114,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -339,3 +339,5 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
|
||||
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4
|
||||
|
||||
4
go.sum
4
go.sum
@@ -490,6 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -660,8 +662,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
]
|
||||
},
|
||||
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
|
||||
"AgentNetwork": {
|
||||
"Zone": "$NETBIRD_AGENT_NETWORK_ZONE"
|
||||
},
|
||||
"Datadir": "",
|
||||
"DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY",
|
||||
"StoreConfig": {
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// TestIsUniqueConstraintError_RecognisesAllThreeDialects — the allocator's
|
||||
// retry loop hinges on this. A missed dialect turns a retryable collision into
|
||||
// a hard provider-create failure.
|
||||
func TestIsUniqueConstraintError_RecognisesAllThreeDialects(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"postgres": errors.New(`ERROR: duplicate key value violates unique constraint (SQLSTATE 23505)`),
|
||||
"mysql": errors.New(`Error 1062 (23000): Duplicate entry 'brave-otter'`),
|
||||
"sqlite": errors.New(`UNIQUE constraint failed: agent_network_settings.subdomain`),
|
||||
} {
|
||||
assert.True(t, isUniqueConstraintError(err), "%s violation must be recognised", name)
|
||||
}
|
||||
|
||||
assert.False(t, isUniqueConstraintError(errors.New("connection refused")),
|
||||
"unrelated errors must not be treated as retryable collisions")
|
||||
}
|
||||
|
||||
// newAllocatorTestStore wires a real sqlite store, mirroring the pattern in
|
||||
// provider_bootstrap_test.go's bootstrapFixture. The allocator tests exercise
|
||||
// bootstrapSettingsIfNeeded directly against a managerImpl built in-package,
|
||||
// so no permissions manager or account manager is needed.
|
||||
func newAllocatorTestStore(t *testing.T) store.Store {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
return st
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_StampsZoneAndTupleLabel — new rows must carry the
|
||||
// configured zone and a tuple label, which together give the tenant a
|
||||
// placement-independent address.
|
||||
func TestBootstrapSettings_StampsZoneAndTupleLabel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
zone: "gateway.example",
|
||||
labelRng: rand.New(rand.NewSource(1)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
require.NotNil(t, settings)
|
||||
|
||||
assert.Equal(t, "gateway.example", settings.Zone, "new row must carry the configured zone")
|
||||
assert.Equal(t, "cluster1.example.com", settings.Cluster)
|
||||
assert.Contains(t, settings.Subdomain, "-", "subdomain must be an adjective-noun tuple label")
|
||||
assert.Equal(t, "account1", settings.AccountID)
|
||||
assert.Equal(t, settings.Subdomain+".gateway.example", settings.Endpoint(),
|
||||
"endpoint must be placement-independent, hanging off the zone rather than the cluster")
|
||||
|
||||
persisted, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, settings.Subdomain, persisted.Subdomain, "returned settings must match the persisted row")
|
||||
assert.Equal(t, "gateway.example", persisted.Zone)
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_RetriesOnCollision forces a duplicate by pre-inserting
|
||||
// a row whose subdomain matches the next label the seeded rng will draw, then
|
||||
// asserts allocation still succeeds with a different label and that no error
|
||||
// escapes.
|
||||
func TestBootstrapSettings_RetriesOnCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
const seed = 7
|
||||
|
||||
// Precompute the label a freshly seeded rng will draw first, without
|
||||
// disturbing the rng the manager will actually use.
|
||||
predictor := rand.New(rand.NewSource(seed))
|
||||
firstDraw := labelgen.PickTuple(predictor)
|
||||
require.NotEmpty(t, firstDraw, "test precondition: label pools must be non-empty")
|
||||
|
||||
// Pre-insert a colliding row on a different account so the allocator's
|
||||
// first attempt hits the unique index and must retry.
|
||||
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: "other-account",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: firstDraw,
|
||||
}), "seeding the colliding row must succeed")
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "allocation must succeed after retrying past the collision")
|
||||
require.NotNil(t, settings)
|
||||
assert.NotEqual(t, firstDraw, settings.Subdomain,
|
||||
"the retried allocation must not reuse the already-taken label")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_IsIdempotent — calling twice for one account returns
|
||||
// the existing row unchanged (the early-return path), and does NOT
|
||||
// re-allocate.
|
||||
func TestBootstrapSettings_IsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(3)),
|
||||
}
|
||||
|
||||
first, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
|
||||
second, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster2.example.com")
|
||||
require.NoError(t, err, "second call must not error")
|
||||
require.NotNil(t, second)
|
||||
|
||||
assert.Equal(t, first.Subdomain, second.Subdomain, "second call must return the existing subdomain unchanged")
|
||||
assert.Equal(t, first.Cluster, second.Cluster, "second call must not repin the cluster to the new hint")
|
||||
|
||||
all, err := st.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone)
|
||||
require.NoError(t, err)
|
||||
var forAccount int
|
||||
for _, s := range all {
|
||||
if s.AccountID == "account1" {
|
||||
forAccount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, forAccount, "exactly one row must exist for the account; no re-allocation")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_FailsAfterExhaustingAttempts — the retry loop's
|
||||
// failure mode. maxSubdomainAllocationAttempts consecutive collisions must
|
||||
// surface an error rather than inserting a duplicate, silently succeeding, or
|
||||
// looping forever.
|
||||
//
|
||||
// Seed 11 was checked to produce maxSubdomainAllocationAttempts distinct
|
||||
// labels from labelgen.PickTuple; a seed that repeated a label would leave
|
||||
// fewer than maxAttempts rows pre-inserted and the allocator would succeed on
|
||||
// the repeat instead of exhausting.
|
||||
func TestBootstrapSettings_FailsAfterExhaustingAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
const seed = 11
|
||||
predictor := rand.New(rand.NewSource(seed))
|
||||
seen := make(map[string]struct{}, maxSubdomainAllocationAttempts)
|
||||
for i := 0; i < maxSubdomainAllocationAttempts; i++ {
|
||||
label := labelgen.PickTuple(predictor)
|
||||
_, dup := seen[label]
|
||||
require.False(t, dup, "test precondition: seed %d must draw %d distinct labels, got a repeat %q at draw %d", seed, maxSubdomainAllocationAttempts, label, i)
|
||||
seen[label] = struct{}{}
|
||||
|
||||
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: fmt.Sprintf("squatter-%d", i),
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: label,
|
||||
}), "seeding colliding row %d must succeed", i)
|
||||
}
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.Error(t, err, "exhausting every attempt to a collision must not silently succeed")
|
||||
assert.Nil(t, settings, "no settings row may be returned on failure")
|
||||
assert.Contains(t, err.Error(), "attempts exhausted")
|
||||
|
||||
_, err = st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no settings row must be persisted for the account when allocation fails")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow covers the
|
||||
// same-account race: Settings' primary key is AccountID, and the
|
||||
// existence pre-check in bootstrapSettingsIfNeeded runs outside the
|
||||
// transaction, so two concurrent first-provider creates for the same
|
||||
// account can both observe NotFound and both proceed to allocate. The
|
||||
// loser's INSERT then fails on the primary key rather than the subdomain
|
||||
// unique index — a string isUniqueConstraintError still recognises — and
|
||||
// must not be treated as a label collision to retry past; it must
|
||||
// re-read and return the winner's row.
|
||||
//
|
||||
// This is scripted against a gomock store rather than driven by real
|
||||
// goroutines against the sqlite test store: NewTestStoreFromSQL caps the
|
||||
// pool at a single open connection (see its startup log,
|
||||
// "max open db connections to 1"), which serialises statement execution
|
||||
// enough that reliably forcing the exact interleaving this test needs —
|
||||
// both pre-checks observing NotFound before either INSERT lands — would
|
||||
// depend on goroutine scheduling rather than the store, making a
|
||||
// real-goroutine version flaky rather than deterministic. Scripting the
|
||||
// exact sequence (pre-check miss, PK-shaped insert failure, re-read hit)
|
||||
// through a MockStore exercises the same re-read branch precisely and
|
||||
// deterministically.
|
||||
func TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
winner := &types.Settings{
|
||||
AccountID: "account1",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: "brave-otter",
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
// The pre-check: no row yet, so this bootstrap proceeds to allocate.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(nil, status.Errorf(status.NotFound, "agent network settings not found")),
|
||||
// The insert loses the race. The message shape is the sqlite wording
|
||||
// for a primary-key violation on account_id (not the subdomain
|
||||
// index); this test locks down that the retry path recognizes that
|
||||
// shape as a race loss and re-reads the winner's row, rather than
|
||||
// misclassifying it as a subdomain conflict.
|
||||
mockStore.EXPECT().
|
||||
ExecuteInTransaction(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, f func(store.Store) error) error {
|
||||
return f(mockStore)
|
||||
}),
|
||||
// The re-read after the PK conflict finds the concurrent winner's row.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(winner, nil),
|
||||
)
|
||||
mockStore.EXPECT().
|
||||
CreateAgentNetworkSettings(gomock.Any(), gomock.Any()).
|
||||
Return(errors.New("UNIQUE constraint failed: agent_network_settings.account_id"))
|
||||
|
||||
m := &managerImpl{
|
||||
store: mockStore,
|
||||
labelRng: rand.New(rand.NewSource(9)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "losing the same-account race must not surface as an error")
|
||||
require.NotNil(t, settings)
|
||||
assert.Same(t, winner, settings, "the loser must return the concurrent winner's row, not retry past it")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_NonRetryableErrorFailsImmediately guards the
|
||||
// isUniqueConstraintError branch itself: a regression that dropped that check
|
||||
// and retried on every ExecuteInTransaction error would leave every other test
|
||||
// in this file green, because none of them feed the loop a non-collision
|
||||
// failure. A generic store error must surface immediately, wrapped, and must
|
||||
// not be retried — asserting ExecuteInTransaction was called exactly once is
|
||||
// what proves the loop didn't retry.
|
||||
func TestBootstrapSettings_NonRetryableErrorFailsImmediately(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(nil, status.Errorf(status.NotFound, "agent network settings not found"))
|
||||
|
||||
mockStore.EXPECT().
|
||||
ExecuteInTransaction(gomock.Any(), gomock.Any()).
|
||||
Return(errors.New("connection refused")).
|
||||
Times(1)
|
||||
|
||||
m := &managerImpl{
|
||||
store: mockStore,
|
||||
labelRng: rand.New(rand.NewSource(5)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.Error(t, err, "a non-collision store error must surface, not be swallowed")
|
||||
assert.Nil(t, settings)
|
||||
assert.Contains(t, err.Error(), "create agent network settings",
|
||||
"the non-retryable error must be wrapped and returned, not retried past")
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint — with a Zone the
|
||||
// hostname's parent is the zone, not the cluster, so the old "strip the first
|
||||
// label and match a cluster" prefilter found nothing and every zone-based
|
||||
// tenant failed to resolve on the auth path.
|
||||
func TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = "gateway.netbird.ai"
|
||||
settings.Subdomain = "brave-otter"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
domain := "brave-otter.gateway.netbird.ai"
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc, "zone-based endpoint must resolve to the owning account's service")
|
||||
assert.Equal(t, domain, svc.Domain)
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint — the
|
||||
// non-breaking guarantee. A row with no Zone still resolves at
|
||||
// <subdomain>.<cluster>, because the subdomain is the first label either way.
|
||||
func TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = ""
|
||||
settings.Subdomain = "swift-heron"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
domain := "swift-heron.eu.proxy.netbird.io"
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc, "legacy cluster-based endpoint must still resolve")
|
||||
assert.Equal(t, domain, svc.Domain)
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot — the label is
|
||||
// globally unique, so a lookup by first label can hit a row that does NOT own
|
||||
// the queried hostname. That must resolve to nothing rather than to the wrong
|
||||
// account's service.
|
||||
func TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = "gateway.netbird.ai"
|
||||
settings.Subdomain = "brave-otter"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, "brave-otter.someone-elses.zone")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, svc, "label matched a different endpoint's parent; must not resolve to the wrong account")
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_UnknownLabel — a hostname whose first label
|
||||
// belongs to no account is a miss, not an error: the caller falls back to the
|
||||
// persisted-service lookup and a returned error would mask that.
|
||||
func TestSynthesizeServiceForDomain_UnknownLabel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, "nobody-home.gateway.netbird.ai")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, svc, "unknown label must be a miss, not an error")
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_DegenerateInput — empty and single-label
|
||||
// hostnames have no dot to cut a subdomain label from, so they resolve to no
|
||||
// service, same as any other unowned hostname. The early-return guard that
|
||||
// catches them is an optimisation (it skips a store round trip that would
|
||||
// only miss anyway), not what makes this case correct — "" and "localhost"
|
||||
// would still come back nil, nil even without it, via the same not-found
|
||||
// fallthrough TestSynthesizeServiceForDomain_UnknownLabel exercises.
|
||||
func TestSynthesizeServiceForDomain_DegenerateInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
for _, domain := range []string{"", "localhost"} {
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err, "domain %q", domain)
|
||||
assert.Nil(t, svc, "domain %q has no subdomain label to look up", domain)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
Return(true, context.Background(), nil).
|
||||
AnyTimes()
|
||||
|
||||
manager := agentnetwork.NewManager(st, perms, nil, nil, "")
|
||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
||||
h := &handler{manager: manager}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Package labelgen produces DNS-safe Agent Network subdomain labels.
|
||||
//
|
||||
// The adjective pool below pairs with the noun pool in words.go to form
|
||||
// `<adjective>-<noun>` labels. It is kept separate because words.go is almost
|
||||
// entirely nouns — drawing both halves from it produced unreadable pairs like
|
||||
// "millet-hammock". Entries are lowercase ASCII, 4-12 chars, free of hyphens
|
||||
// and digits, screened for offensive/brand/region-specific terms, and disjoint
|
||||
// from the noun pool (enforced by TestAdjectives_AreDisjointFromNouns).
|
||||
package labelgen
|
||||
|
||||
// adjectives is the descriptor half of a generated label.
|
||||
var adjectives = []string{
|
||||
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
|
||||
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
|
||||
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
|
||||
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
|
||||
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
|
||||
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
|
||||
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
|
||||
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
|
||||
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
|
||||
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
|
||||
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
|
||||
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
|
||||
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
|
||||
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
|
||||
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
|
||||
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
|
||||
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
|
||||
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
|
||||
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
|
||||
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
|
||||
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
|
||||
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
|
||||
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
|
||||
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
|
||||
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
|
||||
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
|
||||
}
|
||||
@@ -2,11 +2,18 @@
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// pickAttempts caps the random retries before falling back to the
|
||||
// suffixed form. Eight is a soft compromise: with a near-empty taken
|
||||
// set the very first pick almost always succeeds; when the wordlist is
|
||||
// densely populated the fallback eventually fires anyway.
|
||||
const pickAttempts = 8
|
||||
|
||||
var (
|
||||
dedupOnce sync.Once
|
||||
uniqWords []string
|
||||
@@ -30,19 +37,30 @@ func uniqueWords() []string {
|
||||
return uniqWords
|
||||
}
|
||||
|
||||
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
|
||||
// a single DNS label.
|
||||
//
|
||||
// It takes no `taken` set and has no fallback suffix. The noun pool holds 857
|
||||
// entries, which is ample per cluster but a hard ceiling once labels must be
|
||||
// unique across one shared zone; pairing an adjective with a noun spans
|
||||
// len(adjectives) * 857 instead. Uniqueness is enforced by a database
|
||||
// constraint and retried by the caller, rather than guessed from a pre-read
|
||||
// set that a concurrent allocation can invalidate.
|
||||
func PickTuple(rng *rand.Rand) string {
|
||||
nouns := uniqueWords()
|
||||
if len(nouns) == 0 || len(adjectives) == 0 {
|
||||
return ""
|
||||
// PickUnique selects a label not already in `taken`. It tries up to
|
||||
// pickAttempts random picks; on exhaustion it scans the deduplicated
|
||||
// wordlist for any remaining free entry, and if none is left appends
|
||||
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
|
||||
// is responsible for seeding rng (math/rand).
|
||||
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
|
||||
pool := uniqueWords()
|
||||
if len(pool) == 0 {
|
||||
return fallbackSuffix
|
||||
}
|
||||
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
|
||||
|
||||
for i := 0; i < pickAttempts; i++ {
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
for _, w := range pool {
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,78 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPickUnique_DeterministicWithSeededRng locks the property the
|
||||
// caller relies on: same seed + same taken set → same pick. Without
|
||||
// that, the bootstrap flow can't reproduce a label across retries.
|
||||
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
|
||||
taken := map[string]struct{}{}
|
||||
|
||||
rngA := rand.New(rand.NewSource(42))
|
||||
rngB := rand.New(rand.NewSource(42))
|
||||
|
||||
a := PickUnique(rngA, taken, "abcd")
|
||||
b := PickUnique(rngB, taken, "abcd")
|
||||
|
||||
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
|
||||
}
|
||||
|
||||
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
|
||||
// every word in the pool except a handful and confirms PickUnique
|
||||
// finds one of the remaining free entries instead of returning the
|
||||
// fallback form.
|
||||
func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
|
||||
|
||||
free := map[string]struct{}{
|
||||
pool[0]: {},
|
||||
pool[len(pool)/2]: {},
|
||||
pool[len(pool)-1]: {},
|
||||
}
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
if _, ok := free[w]; ok {
|
||||
continue
|
||||
}
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
|
||||
_, isFree := free[got]
|
||||
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
|
||||
assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
|
||||
}
|
||||
|
||||
// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
|
||||
// confirms PickUnique appends the supplied suffix instead of
|
||||
// returning a duplicate.
|
||||
func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
|
||||
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
|
||||
|
||||
prefix := strings.TrimSuffix(got, "-abcd")
|
||||
found := false
|
||||
for _, w := range pool {
|
||||
if w == prefix {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
|
||||
}
|
||||
|
||||
// TestUniqueWords_DropsDuplicates guards against authoring slips in
|
||||
// words.go: every entry must be unique and DNS-safe.
|
||||
func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
@@ -27,82 +99,3 @@ func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
|
||||
}
|
||||
|
||||
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
|
||||
// adjective and a noun, each from its own pool, joined by a single hyphen so
|
||||
// the result stays one DNS label.
|
||||
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
|
||||
nouns := uniqueWords()
|
||||
inNouns := make(map[string]struct{}, len(nouns))
|
||||
for _, w := range nouns {
|
||||
inNouns[w] = struct{}{}
|
||||
}
|
||||
inAdjectives := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
inAdjectives[a] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
for i := 0; i < 200; i++ {
|
||||
got := PickTuple(rng)
|
||||
|
||||
parts := strings.Split(got, "-")
|
||||
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
|
||||
|
||||
_, adjOK := inAdjectives[parts[0]]
|
||||
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
|
||||
_, nounOK := inNouns[parts[1]]
|
||||
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
|
||||
|
||||
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
|
||||
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
|
||||
// are adjectival, and any overlap would let the same word land on both sides.
|
||||
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
|
||||
nouns := make(map[string]struct{}, len(uniqueWords()))
|
||||
for _, w := range uniqueWords() {
|
||||
nouns[w] = struct{}{}
|
||||
}
|
||||
for _, a := range adjectives {
|
||||
_, clash := nouns[a]
|
||||
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
|
||||
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
|
||||
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
_, dup := seen[a]
|
||||
assert.False(t, dup, "Duplicate adjective %q", a)
|
||||
seen[a] = struct{}{}
|
||||
|
||||
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
|
||||
}
|
||||
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
|
||||
}
|
||||
|
||||
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
|
||||
// function of the rng, which is what makes allocation retries reproducible in tests.
|
||||
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
|
||||
a := PickTuple(rand.New(rand.NewSource(42)))
|
||||
b := PickTuple(rand.New(rand.NewSource(42)))
|
||||
assert.Equal(t, a, b, "Same seed must yield the same tuple")
|
||||
}
|
||||
|
||||
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
|
||||
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
|
||||
// yield overwhelmingly distinct values.
|
||||
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
seen := make(map[string]struct{}, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
seen[PickTuple(rng)] = struct{}{}
|
||||
}
|
||||
assert.Greater(t, len(seen), 1900,
|
||||
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// hand-checked to avoid offensive, brand, or region-specific terms.
|
||||
package labelgen
|
||||
|
||||
// words is the pool PickTuple draws its noun from. The slice is intentionally
|
||||
// words is the pool PickUnique selects from. The slice is intentionally
|
||||
// not sorted — random picks distribute across the list naturally.
|
||||
var words = []string{
|
||||
"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",
|
||||
|
||||
@@ -122,10 +122,6 @@ type managerImpl struct {
|
||||
permissionsManager permissions.Manager
|
||||
proxyController proxy.Controller
|
||||
|
||||
// zone is the parent DNS zone stamped onto newly allocated settings rows.
|
||||
// Empty keeps the legacy <subdomain>.<cluster> endpoint form.
|
||||
zone string
|
||||
|
||||
// reconcileCache holds the last set of synthesised proxy mappings
|
||||
// per account so reconcile can emit precise Create/Update/Delete
|
||||
// updates instead of a full re-push on every mutation. Keyed by
|
||||
@@ -133,7 +129,7 @@ type managerImpl struct {
|
||||
reconcileMu sync.Mutex
|
||||
reconcileCache map[string]map[string]*proto.ProxyMapping
|
||||
|
||||
// labelRngMu guards labelRng. PickTuple consumes math/rand.Source
|
||||
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
|
||||
// state; concurrent provider creates would otherwise race.
|
||||
labelRngMu sync.Mutex
|
||||
labelRng *rand.Rand
|
||||
@@ -149,14 +145,12 @@ func NewManager(
|
||||
permissionsManager permissions.Manager,
|
||||
accountManager account.Manager,
|
||||
proxyController proxy.Controller,
|
||||
zone string,
|
||||
) Manager {
|
||||
return &managerImpl{
|
||||
store: store,
|
||||
accountManager: accountManager,
|
||||
permissionsManager: permissionsManager,
|
||||
proxyController: proxyController,
|
||||
zone: zone,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
@@ -309,22 +303,6 @@ func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, pro
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueConstraintError reports whether err is a duplicate-key rejection.
|
||||
//
|
||||
// The equivalent helper in management/server is unexported, so it cannot be
|
||||
// reused from here; this is a deliberate duplicate rather than a new dependency
|
||||
// on that package for a single three-line matcher. Keep the two in sync if a
|
||||
// dialect is added.
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
||||
strings.Contains(msg, "Error 1062 (23000)") || // mysql
|
||||
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
|
||||
}
|
||||
|
||||
func pluralize(n int, singular, plural string) string {
|
||||
if n == 1 {
|
||||
return singular
|
||||
@@ -648,6 +626,12 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
||||
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||
// settings row when missing. The cluster comes from the create-time
|
||||
// hint the dashboard sends (auto-picked from the active cluster list);
|
||||
// the subdomain is picked from the curated wordlist avoiding
|
||||
// collisions on the same cluster. Idempotent: if a row already exists
|
||||
// it is returned untouched and the hint is ignored.
|
||||
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||
// first provider create performs. Pinning the account's cluster and subdomain
|
||||
// is a settings write, so it needs the settings permission on top of the
|
||||
@@ -664,15 +648,6 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
|
||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||
}
|
||||
|
||||
// maxSubdomainAllocationAttempts bounds the allocate-and-insert retry loop in
|
||||
// bootstrapSettingsIfNeeded. Package-level (rather than function-local) so
|
||||
// tests can assert on the exhaustion path without duplicating the literal.
|
||||
const maxSubdomainAllocationAttempts = 10
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network settings
|
||||
// row when missing, allocating a subdomain unique across the whole zone.
|
||||
// Idempotent: if a row already exists it is returned untouched and the
|
||||
// cluster hint is ignored.
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||
@@ -690,66 +665,40 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
// Labels must be unique across the whole zone; the database's unique index
|
||||
// enforces that, and the loop below retries with a fresh label whenever an
|
||||
// attempt is rejected.
|
||||
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
taken := make(map[string]struct{}, len(siblings))
|
||||
for _, s := range siblings {
|
||||
taken[s.Subdomain] = struct{}{}
|
||||
}
|
||||
|
||||
suffix := accountID
|
||||
if len(suffix) > 4 {
|
||||
suffix = suffix[:4]
|
||||
}
|
||||
|
||||
m.labelRngMu.Lock()
|
||||
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
settings := &types.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: providerCluster,
|
||||
Zone: m.zone,
|
||||
AccountID: accountID,
|
||||
Cluster: providerCluster,
|
||||
Subdomain: subdomain,
|
||||
// Logs on by default; usage is collected regardless. Retention bounds
|
||||
// how long full log rows are kept.
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxSubdomainAllocationAttempts; attempt++ {
|
||||
m.labelRngMu.Lock()
|
||||
settings.Subdomain = labelgen.PickTuple(m.labelRng)
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
if settings.Subdomain == "" {
|
||||
// Only reachable if either word pool were emptied; a database
|
||||
// insert of an empty subdomain would collide with the unique
|
||||
// index in a confusing way and produce a broken endpoint like
|
||||
// ".gateway.example". Fail loudly instead of looping or inserting.
|
||||
return nil, fmt.Errorf(
|
||||
"allocate agent network subdomain for account %s: label generator returned an empty label",
|
||||
accountID)
|
||||
}
|
||||
|
||||
// Each attempt gets its own transaction wrapping a single INSERT: on
|
||||
// postgres a failed statement poisons the enclosing transaction, so a
|
||||
// fresh transaction per attempt is what makes the retry loop work on
|
||||
// that dialect at all.
|
||||
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
return transaction.CreateAgentNetworkSettings(ctx, settings)
|
||||
})
|
||||
if err == nil {
|
||||
return settings, nil
|
||||
}
|
||||
if isUniqueConstraintError(err) {
|
||||
// A concurrent bootstrap for this account may have won the race: the
|
||||
// pre-check above is outside the transaction, and the settings PK is
|
||||
// account_id, so the loser's insert fails on the primary key rather
|
||||
// than the subdomain index. Re-read before assuming the label was
|
||||
// taken, so a same-account race resolves immediately instead of
|
||||
// burning every remaining attempt on the same primary-key conflict.
|
||||
if existing, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID); getErr == nil {
|
||||
return existing, nil
|
||||
}
|
||||
log.WithContext(ctx).Tracef(
|
||||
"agent-network subdomain %q taken, retrying (attempt %d/%d)",
|
||||
settings.Subdomain, attempt, maxSubdomainAllocationAttempts)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create agent network settings: %w", err)
|
||||
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"allocate agent network subdomain for account %s: %d attempts exhausted",
|
||||
accountID, maxSubdomainAllocationAttempts)
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// ListConsumption returns every consumption row recorded for the
|
||||
|
||||
@@ -48,7 +48,7 @@ func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil, ""),
|
||||
manager: NewManager(st, perms, accounts, nil),
|
||||
store: st,
|
||||
perms: perms,
|
||||
}
|
||||
|
||||
@@ -116,46 +116,45 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
|
||||
}
|
||||
|
||||
// SynthesizeServiceForDomain resolves a single agent-network service by its
|
||||
// endpoint hostname. Both endpoint shapes put the account's label in the first
|
||||
// DNS label — <subdomain>.<cluster> and <subdomain>.<zone> — and the label is
|
||||
// globally unique, so this is a single indexed lookup for either shape. It
|
||||
// synthesises only the owning account rather than every tenant on a cluster,
|
||||
// which is what auth/session paths previously paid. Returns nil (no error) when
|
||||
// no account owns the hostname.
|
||||
// public endpoint domain. It lists the (few) settings rows on the domain's
|
||||
// cluster, matches the one whose endpoint equals the domain, and synthesises
|
||||
// only that account — avoiding full per-account synthesis for every tenant on
|
||||
// the cluster, which is what auth/session paths previously paid. Returns nil
|
||||
// (no error) when no account owns the domain.
|
||||
func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) {
|
||||
domain = strings.TrimSpace(domain)
|
||||
subdomain, _, found := strings.Cut(domain, ".")
|
||||
if !found || subdomain == "" {
|
||||
return nil, nil //nolint:nilnil // no label to resolve: not an owned endpoint
|
||||
}
|
||||
|
||||
settings, err := s.GetAgentNetworkSettingsBySubdomain(ctx, store.LockingStrengthNone, subdomain)
|
||||
if err != nil {
|
||||
var sErr *status.Error
|
||||
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
|
||||
return nil, nil //nolint:nilnil // no account owns the label
|
||||
cluster := clusterFromDomain(domain)
|
||||
if domain != "" && cluster != "" {
|
||||
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
// A real store failure must surface: the caller treats nil as "not an
|
||||
// agent-network endpoint" and would silently mask a database error.
|
||||
return nil, fmt.Errorf("get agent network settings by subdomain: %w", err)
|
||||
}
|
||||
|
||||
// The label is unique but the parent is not implied by it: a row owning
|
||||
// "brave-otter" does not own "brave-otter.some-other.zone".
|
||||
if settings.Endpoint() != domain {
|
||||
return nil, nil //nolint:nilnil // label matched a different endpoint
|
||||
}
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
for _, settings := range settingsRows {
|
||||
if settings == nil || settings.Endpoint() != domain {
|
||||
continue
|
||||
}
|
||||
services, serr := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil //nolint:nilnil // owner found but it emits no service
|
||||
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
|
||||
}
|
||||
|
||||
// clusterFromDomain returns the cluster portion of an endpoint domain (every
|
||||
// label after the first).
|
||||
func clusterFromDomain(domain string) string {
|
||||
if i := strings.IndexByte(domain, '.'); i >= 0 {
|
||||
return domain[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SynthesizeServices builds the in-memory reverse-proxy service that
|
||||
@@ -935,12 +934,6 @@ func buildAccountService(
|
||||
middlewares []rpservice.MiddlewareConfig,
|
||||
sessionPriv, sessionPub string,
|
||||
) *rpservice.Service {
|
||||
// The proxy that serves this tenant — a dedicated proxy when one has been
|
||||
// assigned, else the shared cluster. This is the value mesh-DNS peer
|
||||
// selection and the connect-snapshot filter both join on.
|
||||
servingProxy := settings.ServingProxy()
|
||||
// The shared cluster address remains the placeholder target's ID; only the
|
||||
// advertised proxy address follows ServingProxy().
|
||||
cluster := settings.Cluster
|
||||
domain := settings.Endpoint()
|
||||
serviceID := SynthesizedServiceIDPrefix + accountID
|
||||
@@ -950,8 +943,7 @@ func buildAccountService(
|
||||
AccountID: accountID,
|
||||
Name: "agent-network-" + accountID,
|
||||
Domain: domain,
|
||||
ProxyCluster: servingProxy,
|
||||
DNSZone: settings.Zone, // empty for legacy rows → unchanged behavior
|
||||
ProxyCluster: cluster,
|
||||
Mode: rpservice.ModeHTTP,
|
||||
Enabled: true,
|
||||
Private: true,
|
||||
|
||||
@@ -1246,100 +1246,3 @@ func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) {
|
||||
require.Error(t, err, "synthesis must refuse a provider with no api key")
|
||||
assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential")
|
||||
}
|
||||
|
||||
// TestBuildAccountService_ProxyClusterFollowsServingProxyAddress — the whole
|
||||
// point of the column: the synthesized service must advertise the private
|
||||
// proxy's address, because that value is what mesh-DNS peer selection and the
|
||||
// connect-snapshot filter both join on. TargetId must NOT move with it — it
|
||||
// identifies the placeholder target the router rewrites per request, and only
|
||||
// the advertised proxy address follows ServingProxy().
|
||||
func TestBuildAccountService_ProxyClusterFollowsServingProxyAddress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
settings := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Zone: "gateway.netbird.ai",
|
||||
Subdomain: "brave-otter",
|
||||
ServingProxyAddress: "brave-otter.gateway.netbird.ai",
|
||||
}
|
||||
provider := newSynthTestProvider()
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
|
||||
expectSynthBaseInputs(mockStore, ctx, settings,
|
||||
[]*types.Provider{provider},
|
||||
[]*types.Policy{policy},
|
||||
[]*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServices(ctx, mockStore, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
svc := services[0]
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", svc.ProxyCluster,
|
||||
"ProxyCluster must advertise the private proxy's address once ServingProxyAddress is set")
|
||||
require.Len(t, svc.Targets, 1)
|
||||
assert.Equal(t, testCluster, svc.Targets[0].TargetId,
|
||||
"TargetId is the noop placeholder target and must stay pinned to the shared cluster, not the serving proxy")
|
||||
}
|
||||
|
||||
// TestSynthesizeServicesForCluster_ExcludesPrivatelyServedTenant — a tenant
|
||||
// moved to a private proxy must drop out of the SHARED proxy's connect
|
||||
// snapshot, or both proxies would serve it. The existing
|
||||
// `svc.ProxyCluster == clusterAddr` filter does this for free once ProxyCluster
|
||||
// is the tenant hostname; this test proves the handoff rather than assuming it.
|
||||
func TestSynthesizeServicesForCluster_ExcludesPrivatelyServedTenant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
|
||||
privatelyServed := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Subdomain: testSubdomain,
|
||||
ServingProxyAddress: "brave-otter.gateway.netbird.ai",
|
||||
}
|
||||
|
||||
t.Run("privately served tenant is excluded from the shared cluster snapshot", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, testCluster).
|
||||
Return([]*types.Settings{privatelyServed}, nil)
|
||||
expectSynthBaseInputs(mockStore, ctx, privatelyServed,
|
||||
[]*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServicesForCluster(ctx, mockStore, testCluster)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "a tenant served by a private proxy must not appear in the shared cluster's snapshot")
|
||||
})
|
||||
|
||||
t.Run("clearing ServingProxyAddress makes the tenant reappear", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
sharedAgain := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Subdomain: testSubdomain,
|
||||
}
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, testCluster).
|
||||
Return([]*types.Settings{sharedAgain}, nil)
|
||||
expectSynthBaseInputs(mockStore, ctx, sharedAgain,
|
||||
[]*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServicesForCluster(ctx, mockStore, testCluster)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "clearing ServingProxyAddress must return the tenant to the shared cluster's snapshot")
|
||||
assert.Equal(t, testCluster, services[0].ProxyCluster)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,37 +11,13 @@ import (
|
||||
// the long-term aggregate and are retained independently.
|
||||
const DefaultAccessLogRetentionDays = 30
|
||||
|
||||
// Settings is the per-account agent-network configuration row. One row per
|
||||
// account. The public endpoint agents call is `<subdomain>.<zone>` when a
|
||||
// zone is set, else `<subdomain>.<cluster>`. Cluster, Subdomain and Zone are
|
||||
// immutable once written; ServingProxyAddress is the one mutable column,
|
||||
// naming which proxy currently serves the account.
|
||||
// Settings is the per-account agent-network configuration row. One
|
||||
// row per account. Cluster + Subdomain are immutable once written and
|
||||
// produce the public endpoint agents call (`<subdomain>.<cluster>`).
|
||||
type Settings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
|
||||
// Zone is the placement-independent parent zone the endpoint lives under,
|
||||
// captured from server config when the row is allocated. Immutable, like
|
||||
// Cluster and Subdomain.
|
||||
//
|
||||
// Empty means "legacy": the endpoint falls back to <subdomain>.<cluster>,
|
||||
// which embeds the serving proxy. Existing rows and any deployment that
|
||||
// configures no zone keep that behaviour unchanged.
|
||||
Zone string
|
||||
|
||||
// ServingProxyAddress is the address of the proxy currently serving this
|
||||
// account's gateway. Empty means the account is served by the shared proxy
|
||||
// at Cluster; set means a dedicated proxy serves it, and the value is that
|
||||
// proxy's address — for a per-account proxy, the account's own gateway
|
||||
// hostname.
|
||||
//
|
||||
// This is the only mutable column on this row. Cluster, Subdomain and Zone
|
||||
// are fixed once written, but moving an account onto a dedicated proxy — and
|
||||
// moving it back — is exactly one write here. Nothing in this repository
|
||||
// writes it: it is set by whatever external process assigns dedicated
|
||||
// proxies, and its zero value preserves existing behaviour for every current
|
||||
// row and every deployment that assigns none.
|
||||
ServingProxyAddress string
|
||||
|
||||
// Account-level collection controls sourced by the synthesizer.
|
||||
// EnableLogCollection gates the per-request access-log trail and defaults
|
||||
@@ -66,31 +42,12 @@ type Settings struct {
|
||||
// schema cohesive.
|
||||
func (Settings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at.
|
||||
//
|
||||
// With a Zone set this is `<subdomain>.<zone>` — deliberately independent of
|
||||
// which proxy serves the account, so moving between a shared and a private
|
||||
// proxy (or between clusters) is a DNS change only and never alters the
|
||||
// tenant's address. With no Zone it falls back to the legacy
|
||||
// `<subdomain>.<cluster>` form.
|
||||
// Endpoint returns the bare hostname agents reach this account at:
|
||||
// `<subdomain>.<cluster>`.
|
||||
func (s *Settings) Endpoint() string {
|
||||
if s.Zone != "" {
|
||||
return s.Subdomain + "." + s.Zone
|
||||
}
|
||||
return s.Subdomain + "." + s.Cluster
|
||||
}
|
||||
|
||||
// ServingProxy returns the address of the proxy that serves this account's
|
||||
// gateway: the dedicated proxy when one has been assigned, otherwise the shared
|
||||
// cluster. This is the value the synthesized service advertises as
|
||||
// ProxyCluster, which is what mesh-DNS peer selection joins on.
|
||||
func (s *Settings) ServingProxy() string {
|
||||
if s.ServingProxyAddress != "" {
|
||||
return s.ServingProxyAddress
|
||||
}
|
||||
return s.Cluster
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the settings as the API representation.
|
||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
created := s.CreatedAt
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEndpoint_PrefersZoneOverCluster locks the decoupling: when a Zone is set
|
||||
// the hostname must NOT embed the serving cluster, so moving a tenant between
|
||||
// proxies never changes their address.
|
||||
func TestEndpoint_PrefersZoneOverCluster(t *testing.T) {
|
||||
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.Endpoint())
|
||||
}
|
||||
|
||||
// TestEndpoint_FallsBackToClusterWhenZoneEmpty is the compatibility guarantee:
|
||||
// existing rows (and every self-hosted deployment, which sets no zone) keep
|
||||
// exactly the address they have today.
|
||||
func TestEndpoint_FallsBackToClusterWhenZoneEmpty(t *testing.T) {
|
||||
s := &Settings{Subdomain: "otter", Cluster: "eu.proxy.netbird.io"}
|
||||
assert.Equal(t, "otter.eu.proxy.netbird.io", s.Endpoint())
|
||||
}
|
||||
|
||||
// TestToAPIResponse_ExposesZoneAndDerivedEndpoint — the dashboard renders
|
||||
// Endpoint verbatim, so it must reflect the zone.
|
||||
func TestToAPIResponse_ExposesZoneAndDerivedEndpoint(t *testing.T) {
|
||||
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
|
||||
resp := s.ToAPIResponse()
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", resp.Endpoint)
|
||||
}
|
||||
|
||||
// TestServingProxy_PrefersColumnOverCluster — a provisioned tenant is served by
|
||||
// its own proxy, whose address is its hostname, not the shared cluster.
|
||||
func TestServingProxy_PrefersColumnOverCluster(t *testing.T) {
|
||||
s := &Settings{Cluster: "eu.proxy.netbird.io", ServingProxyAddress: "brave-otter.gateway.netbird.ai"}
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.ServingProxy())
|
||||
}
|
||||
|
||||
// TestServingProxy_FallsBackToCluster is the compatibility guarantee: every
|
||||
// existing row, and every self-hosted deployment, is served by the shared proxy.
|
||||
func TestServingProxy_FallsBackToCluster(t *testing.T) {
|
||||
s := &Settings{Cluster: "eu.proxy.netbird.io"}
|
||||
assert.Equal(t, "eu.proxy.netbird.io", s.ServingProxy())
|
||||
}
|
||||
@@ -255,13 +255,6 @@ type Service struct {
|
||||
Private bool
|
||||
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
|
||||
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
|
||||
// DNSZone is the parent zone a private service's synthesized mesh A record
|
||||
// hangs under, for the case where that zone cannot be derived from
|
||||
// ProxyCluster or a validated custom domain — i.e. placement-free
|
||||
// agent-network endpoints, which are <subdomain>.<zone>. In-memory only:
|
||||
// set by the agent-network synthesizer on services it builds per read,
|
||||
// never stored and never exposed on the API or the proxy wire.
|
||||
DNSZone string `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
||||
@@ -1419,7 +1412,6 @@ func (s *Service) Copy() *Service {
|
||||
PortAutoAssigned: s.PortAutoAssigned,
|
||||
Private: s.Private,
|
||||
AccessGroups: accessGroups,
|
||||
DNSZone: s.DNSZone,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1215,17 +1215,6 @@ func TestService_Copy_RoundtripsPrivate(t *testing.T) {
|
||||
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
|
||||
}
|
||||
|
||||
// TestServiceCopy_PreservesDNSZone — DNSZone is in-memory only, so it is easy
|
||||
// to omit from Copy()'s explicit field list; if it is dropped, a copied
|
||||
// account silently loses its zone apex and the tenant's endpoint resolves to
|
||||
// nothing.
|
||||
func TestServiceCopy_PreservesDNSZone(t *testing.T) {
|
||||
svc := &Service{Domain: "brave-otter.gateway.netbird.ai", DNSZone: "gateway.netbird.ai"}
|
||||
cp := svc.Copy()
|
||||
require.NotNil(t, cp)
|
||||
assert.Equal(t, "gateway.netbird.ai", cp.DNSZone)
|
||||
}
|
||||
|
||||
func TestService_APIRoundtrip_Private(t *testing.T) {
|
||||
enabled := true
|
||||
private := true
|
||||
|
||||
@@ -204,15 +204,6 @@ type AgentNetwork struct {
|
||||
// prefill with). An explicitly configured path that fails to load
|
||||
// fails startup; runtime reload errors keep the previous table.
|
||||
PricingDefaultsFile string
|
||||
|
||||
// Zone is the parent DNS zone that Agent Network gateway endpoints are
|
||||
// allocated under, producing <subdomain>.<zone>.
|
||||
//
|
||||
// Empty (the default) preserves the legacy behaviour of deriving the
|
||||
// endpoint from the serving cluster, so self-hosted deployments are
|
||||
// unaffected. It is captured onto each settings row when that row is
|
||||
// created; changing it later does not move existing tenants.
|
||||
Zone string
|
||||
}
|
||||
|
||||
// ReverseProxy contains reverse proxy configuration in front of management.
|
||||
|
||||
@@ -202,7 +202,6 @@ func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager {
|
||||
s.PermissionsManager(),
|
||||
s.AccountManager(),
|
||||
s.ServiceProxyController(),
|
||||
s.Config.AgentNetwork.Zone,
|
||||
)
|
||||
// Sweep expired agent-network access logs per account retention,
|
||||
// reusing the reverse-proxy cleanup interval config.
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
|
||||
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{
|
||||
AccountID: accountID,
|
||||
@@ -82,7 +82,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
// Creating a provider bootstraps the settings row (cluster + subdomain).
|
||||
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// Real agentnetwork manager wired to the real account manager. proxyController
|
||||
// is nil (no gRPC cluster fan-out here) — the reconcile still fires
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
|
||||
@@ -334,30 +334,6 @@ func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStr
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain returns the settings row that owns the
|
||||
// given subdomain label. The label is globally unique (enforced by
|
||||
// idx_agent_network_settings_subdomain_unique), so at most one row can match,
|
||||
// which makes this an indexed point lookup rather than a scan.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings agentNetworkTypes.Settings
|
||||
result := tx.Take(&settings, "subdomain = ?", subdomain)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings for subdomain %s not found", subdomain)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by subdomain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by subdomain from store")
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SaveAgentNetworkSettings upserts the per-account Agent Network
|
||||
// settings row.
|
||||
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
@@ -370,39 +346,6 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings inserts a new settings row.
|
||||
//
|
||||
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
|
||||
// returns the driver error unwrapped. Both properties are required by the
|
||||
// subdomain allocator: it relies on the unique index rejecting a duplicate
|
||||
// label, and on being able to recognise that rejection so it can retry with a
|
||||
// fresh label instead of surfacing an error.
|
||||
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
if err := s.db.Create(settings).Error; err != nil {
|
||||
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress points the account's gateway at a specific
|
||||
// serving proxy, or clears it (address == "") to return the account to the
|
||||
// shared proxy. Scoped to the one column on purpose: this runs concurrently
|
||||
// with unrelated settings updates, and a full-row upsert would clobber them.
|
||||
func (s *SqlStore) SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error {
|
||||
result := s.db.Model(&agentNetworkTypes.Settings{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Update("serving_proxy_address", address)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to set agent network serving proxy address: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to set agent network serving proxy address")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption atomically upserts the consumption
|
||||
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
|
||||
// and adds the supplied deltas. Concurrent calls from multiple proxy
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
)
|
||||
|
||||
// TestAgentNetworkSettings_SubdomainIsGloballyUnique is the guard for the whole
|
||||
// allocation scheme: the label is now globally unique rather than per-cluster,
|
||||
// and the allocator depends on the DATABASE saying no. Two different accounts on
|
||||
// two different clusters must not be able to hold the same subdomain.
|
||||
func TestAgentNetworkSettings_SubdomainIsGloballyUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
first := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-unique-1",
|
||||
Cluster: "eu.proxy.example",
|
||||
Subdomain: "brave-otter",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, first), "first insert must succeed")
|
||||
|
||||
// Deliberately a different account AND a different cluster: under the old
|
||||
// per-cluster scheme this was legal, and it is exactly what must now fail.
|
||||
second := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-unique-2",
|
||||
Cluster: "us.proxy.example",
|
||||
Subdomain: "brave-otter",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
err = s.CreateAgentNetworkSettings(ctx, second)
|
||||
require.Error(t, err, "duplicate subdomain must be rejected by the unique index")
|
||||
|
||||
// The allocator recognises conflicts by matching the driver's message, so an
|
||||
// error that does not carry a unique-violation signature is useless to it
|
||||
// even though it is non-nil. These are the three signatures management's
|
||||
// isUniqueConstraintError matches (postgres / mysql / sqlite).
|
||||
msg := err.Error()
|
||||
assert.True(t,
|
||||
strings.Contains(msg, "(SQLSTATE 23505)") ||
|
||||
strings.Contains(msg, "Error 1062 (23000)") ||
|
||||
strings.Contains(msg, "UNIQUE constraint failed"),
|
||||
"error must be the raw driver error, recognisable as a unique violation; got %q", msg)
|
||||
}
|
||||
|
||||
// TestAgentNetworkSettings_CreateThenReadBack keeps CreateAgentNetworkSettings
|
||||
// honest as an insert path: the row it writes must be fully readable, including
|
||||
// the new Zone column.
|
||||
func TestAgentNetworkSettings_CreateThenReadBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
want := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-readback-1",
|
||||
Cluster: "eu.proxy.example",
|
||||
Subdomain: "swift-heron",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, want))
|
||||
|
||||
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, "acc-readback-1")
|
||||
require.NoError(t, err, "the inserted row must be readable")
|
||||
assert.Equal(t, "swift-heron", got.Subdomain)
|
||||
assert.Equal(t, "gateway.example", got.Zone, "the Zone column must round-trip")
|
||||
assert.Equal(t, "swift-heron.gateway.example", got.Endpoint(), "endpoint derives from zone")
|
||||
}
|
||||
@@ -361,10 +361,7 @@ type Store interface {
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
|
||||
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error)
|
||||
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error
|
||||
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
|
||||
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
|
||||
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
|
||||
@@ -661,28 +658,6 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
// Enforce globally-unique agent-network subdomains.
|
||||
//
|
||||
// Uniqueness used to be per-cluster and advisory (a pre-read
|
||||
// "taken" set with no DB constraint). Once the endpoint hangs off a
|
||||
// shared zone the label must be unique across that whole zone, and
|
||||
// the allocator depends on the database rejecting duplicates so it
|
||||
// can retry with a fresh label.
|
||||
//
|
||||
// The pre-existing idx_agent_network_settings_cluster_subdomain is
|
||||
// left in place: it is non-unique and indexes subdomain alone
|
||||
// (Cluster carries no tag), so it neither conflicts nor suffices.
|
||||
// It must also stay for a second, load-bearing reason on mysql:
|
||||
// its gorm:"index:" tag on the Subdomain field is what makes gorm
|
||||
// size that column as varchar(191) instead of longtext. mysql
|
||||
// cannot put a longtext column in a unique index at all, so
|
||||
// dropping this "redundant" index as unneeded would silently
|
||||
// break the migration above on that dialect.
|
||||
return migration.CreateIndexIfNotExists[agentNetworkTypes.Settings](
|
||||
ctx, db, "idx_agent_network_settings_subdomain_unique", "subdomain",
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,20 +268,6 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateAgentNetworkSettings", ctx, settings)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings.
|
||||
func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkUsage mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1716,21 +1702,6 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStren
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster)
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*types.Settings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsBySubdomain", ctx, lockStrength, subdomain)
|
||||
ret0, _ := ret[0].(*types.Settings)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain indicates an expected call of GetAgentNetworkSettingsBySubdomain.
|
||||
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsBySubdomain(ctx, lockStrength, subdomain interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsBySubdomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsBySubdomain), ctx, lockStrength, subdomain)
|
||||
}
|
||||
|
||||
// GetAgentNetworkUsageRows mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3666,20 +3637,6 @@ func (mr *MockStoreMockRecorder) SaveUsers(ctx, users interface{}) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUsers", reflect.TypeOf((*MockStore)(nil).SaveUsers), ctx, users)
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress mocks base method.
|
||||
func (m *MockStore) SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetAgentNetworkServingProxyAddress", ctx, accountID, address)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress indicates an expected call of SetAgentNetworkServingProxyAddress.
|
||||
func (mr *MockStoreMockRecorder) SetAgentNetworkServingProxyAddress(ctx, accountID, address interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAgentNetworkServingProxyAddress", reflect.TypeOf((*MockStore)(nil).SetAgentNetworkServingProxyAddress), ctx, accountID, address)
|
||||
}
|
||||
|
||||
// SetFieldEncrypt mocks base method.
|
||||
func (m *MockStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -254,7 +254,6 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
zonesByApex := map[string]*nbdns.CustomZone{}
|
||||
var skippedNoZoneApex []string
|
||||
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private {
|
||||
@@ -273,15 +272,6 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
|
||||
serviceDomainZone := a.privateServiceDomainZone(svc)
|
||||
if serviceDomainZone == "" {
|
||||
// This service passed every gate above (enabled, private,
|
||||
// AccessGroups, connected proxy peers) and would otherwise have
|
||||
// emitted a record, but its domain matches neither its DNSZone,
|
||||
// its ProxyCluster, nor any validated custom-domain row. Collected
|
||||
// rather than logged here — this runs per peer x per service, and
|
||||
// logging inline here would reintroduce the per-peer noise the
|
||||
// "0 zones" diagnostic below deliberately avoids.
|
||||
skippedNoZoneApex = append(skippedNoZoneApex,
|
||||
fmt.Sprintf("%s(domain=%s cluster=%s dns_zone=%q)", svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -335,10 +325,6 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
|
||||
}
|
||||
}
|
||||
if len(skippedNoZoneApex) > 0 {
|
||||
log.Debugf("private-zone synth: peer %s account %s skipped %d service(s) with no matching zone apex: %s",
|
||||
peerID, a.Id, len(skippedNoZoneApex), strings.Join(skippedNoZoneApex, ", "))
|
||||
}
|
||||
|
||||
out := make([]nbdns.CustomZone, 0, len(zonesByApex))
|
||||
for _, zone := range zonesByApex {
|
||||
@@ -358,19 +344,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
}
|
||||
|
||||
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
|
||||
// checking its DNSZone, then the proxy cluster domain, then the custom domains.
|
||||
// looking at the proxy cluster domain then the custom domains.
|
||||
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
|
||||
// Placement-free endpoints (<subdomain>.<zone>) carry their zone
|
||||
// explicitly: it is server config, so it matches neither the serving
|
||||
// proxy's address nor any per-account custom-domain row. Checked first so
|
||||
// the apex stays the zone even once ProxyCluster becomes the tenant
|
||||
// hostname itself (which happens when a dedicated per-account proxy serves
|
||||
// it), which would otherwise make the apex the full hostname and churn the
|
||||
// client's zone set when a tenant moves between proxies.
|
||||
if svc.DNSZone != "" && domainFromSuffix(svc.Domain, svc.DNSZone) {
|
||||
return svc.DNSZone
|
||||
}
|
||||
|
||||
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
|
||||
return svc.ProxyCluster
|
||||
}
|
||||
|
||||
@@ -423,39 +423,6 @@ func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T)
|
||||
"only the 4 private custom services surface in the custom zone (public one excluded)")
|
||||
}
|
||||
|
||||
// TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex — a
|
||||
// zone-based tenant still served by the SHARED proxy has a hostname whose
|
||||
// parent is the zone, matching neither ProxyCluster nor any validated
|
||||
// custom-domain row. Without DNSZone the apex resolves to "" and the service is
|
||||
// skipped entirely, so the tenant's endpoint resolves to nothing.
|
||||
func TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
svc := account.Services[0]
|
||||
svc.Domain = "brave-otter.gateway.netbird.ai"
|
||||
svc.DNSZone = "gateway.netbird.ai"
|
||||
// ProxyCluster stays the shared cluster address — the pre-private cohort.
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 1, "a zone-based endpoint must still produce one zone")
|
||||
assert.Equal(t, "gateway.netbird.ai.", zones[0].Domain, "apex must be the placement-free zone, not the cluster")
|
||||
require.Len(t, zones[0].Records, 1)
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai.", zones[0].Records[0].Name)
|
||||
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData, "still points at the serving proxy peer")
|
||||
}
|
||||
|
||||
// TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped locks the
|
||||
// scope of the fix: a service matching no cluster suffix, no validated custom
|
||||
// domain, AND carrying no DNSZone must keep resolving to nothing. A blanket
|
||||
// "use the parent domain" fallback would hand it mesh DNS and bypass domain
|
||||
// validation.
|
||||
func TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Services[0].Domain = "api.unvalidated.example.com"
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
assert.Empty(t, zones, "no cluster suffix, no validated Domains row, no DNSZone → no records")
|
||||
}
|
||||
|
||||
// recordNames returns the record names of a zone for order-independent assertions.
|
||||
func recordNames(zone nbdns.CustomZone) []string {
|
||||
names := make([]string, 0, len(zone.Records))
|
||||
|
||||
@@ -53,7 +53,7 @@ func newChainIntegration(t *testing.T) *chainIntegrationFixture {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
manager := agentnetwork.NewManager(st, nil, nil, nil, "")
|
||||
manager := agentnetwork.NewManager(st, nil, nil, nil)
|
||||
|
||||
server := &mgmtgrpc.ProxyServiceServer{}
|
||||
server.SetAgentNetworkLimitsService(manager)
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
anMgr := agentnetwork.NewManager(st, nil, nil, nil, "")
|
||||
anMgr := agentnetwork.NewManager(st, nil, nil, nil)
|
||||
server := &mgmtgrpc.ProxyServiceServer{}
|
||||
server.SetAgentNetworkLimitsService(anMgr)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ type Client interface {
|
||||
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
|
||||
GetServerURL() string
|
||||
// IsHealthy returns the current connection status without blocking.
|
||||
// Used by the engine to monitor connectivity in the background.
|
||||
|
||||
@@ -436,49 +436,6 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkMap return with the network map
|
||||
func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) {
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
if err != nil {
|
||||
log.Debugf("failed getting Management Service public key: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancelStream := context.WithCancel(c.ctx)
|
||||
defer cancelStream()
|
||||
stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open Management Service stream: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.CloseSend()
|
||||
}()
|
||||
|
||||
update, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
log.Debugf("Management stream has been closed by server: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("disconnected from Management Service sync stream: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decryptedResp := &proto.SyncResponse{}
|
||||
err = encryption.DecryptMessage(*serverPubKey, c.key, update.Body, decryptedResp)
|
||||
if err != nil {
|
||||
log.Errorf("failed decrypting update message from Management Service: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if decryptedResp.GetNetworkMap() == nil {
|
||||
return nil, fmt.Errorf("invalid msg, required network map")
|
||||
}
|
||||
|
||||
return decryptedResp.GetNetworkMap(), nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
|
||||
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}
|
||||
|
||||
|
||||
@@ -94,11 +94,6 @@ func (m *MockClient) HealthCheck() error {
|
||||
return m.HealthCheckFunc()
|
||||
}
|
||||
|
||||
// GetNetworkMap mock implementation of GetNetworkMap from Client interface.
|
||||
func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetServerURL mock implementation of GetServerURL from mgm.Client interface
|
||||
func (m *MockClient) GetServerURL() string {
|
||||
if m.GetServerURLFunc == nil {
|
||||
|
||||
Reference in New Issue
Block a user