Compare commits

..

4 Commits

Author SHA1 Message Date
Zoltán Papp
081f2d153b [client] Extract SSH JWT cache into shared jwtcache package
Move the daemon's memguard-backed JWT token cache from client/server
into client/ssh/jwtcache so it can be reused outside the daemon
process. The TTL derivation from Config.SSHJWTCacheTTL (nil=default,
0=disabled) moves into jwtcache.ResolveTTL.

Wire the cache into the iOS SDK's SSH client: iOS has no daemon to
delegate caching to, so without it every reconnect forced the user
through the browser OAuth device-code flow. The cache lives on the
long-lived Client (the app creates a new SSHClient per session) and
uses the same config-driven TTL semantics as the daemon.
2026-08-02 10:04:12 +02:00
Zoltán Papp
90bb5e7c0f Merge remote-tracking branch 'origin/main' into feature/ios-ssh 2026-08-02 09:56:00 +02:00
Zoltán Papp
1381dcf919 Merge tag 'v0.73.2' into feature/ios-ssh
Resolve conflict in client/ios/NetBirdSDK/client.go: keep v0.73.2's
thread-safe state handling (stateMu, setState, stateSnapshot) and layer
the SSH client's sshState() helper on top of stateSnapshot().

Add missing imports (strconv, internal/auth, internal/profilemanager) to
ssh_client.go that the SSH feature branch referenced but never imported.
2026-06-26 14:16:03 +02:00
Zoltan Papp
7ea0882975 [ios] add SSHClient gomobile binding for in-app terminal
Exposes SSHClient + SSHTerminalListener to the iOS app, mirroring the
Android binding. Connect() auto-detects the server type via banner
inspection and selects the auth path: NetBird-SSH with JWT triggers the
device-code OAuth flow via the existing URLOpener; NetBird-SSH without
JWT uses the NetBird private key; regular SSH falls back to the NetBird
key then optional password. The client dials through the running tunnel
with a plain net.Dialer and streams PTY output back to Swift via the
gomobile-bound listener for rendering in the terminal view.

Adds a config field and sshState() accessor to the iOS Client so the
SSH client can reach the active config and engine.
2026-05-27 18:29:12 +02:00
91 changed files with 1344 additions and 5523 deletions

View File

@@ -16,8 +16,6 @@ jobs:
const allowedTags = [
'management',
'client',
'android',
'ios',
'signal',
'proxy',
'relay',

View File

@@ -93,9 +93,7 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- netbird
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
@@ -116,9 +114,7 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- netbird
rpm:
signature:

View File

@@ -57,12 +57,6 @@ 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())
}
@@ -82,8 +76,6 @@ 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
@@ -104,12 +96,11 @@ type Client struct {
extendCancel context.CancelFunc
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
c.config = cfg
c.cacheDir = cacheDir
c.cfgPath = cfgPath
c.connectClient = cc
}
@@ -119,16 +110,6 @@ 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()
@@ -181,7 +162,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
auth := NewAuthWithConfig(ctx, cfg)
err = auth.login(urlOpener, isAndroidTV)
if err != nil {
return err
@@ -189,7 +170,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, cfgFile, connectClient)
c.setState(cfg, cacheDir, 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()
@@ -230,7 +211,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, cfgFile, connectClient)
c.setState(cfg, cacheDir, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -259,24 +240,6 @@ 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) {

View File

@@ -4,8 +4,6 @@ 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"
@@ -38,20 +36,12 @@ 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.UpdateOrCreateConfig(inputCfg)
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
if err != nil {
return nil, err
}
@@ -63,14 +53,11 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
}, nil
}
// 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 {
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
return &Auth{
ctx: ctx,
config: config,
cfgPath: cfgPath,
ctx: ctx,
config: config,
}
}
@@ -163,14 +150,12 @@ 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)
@@ -178,42 +163,17 @@ 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)

View File

@@ -1,51 +0,0 @@
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)
}
}

View File

@@ -13,17 +13,18 @@ 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
// 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
ID string
Name string
IsActive bool
}
@@ -100,7 +101,6 @@ 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,22 +123,7 @@ 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,
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)
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
}
// SwitchProfile switches to a different profile
@@ -200,11 +185,6 @@ 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
}

View File

@@ -1,108 +0,0 @@
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
}

View File

@@ -1,161 +0,0 @@
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)
}

View File

@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
}
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
cfg, cfgPath, cc := c.authSnapshot()
cfg, _, cc := c.stateSnapshot()
if cfg == nil || cc == nil {
return fmt.Errorf("engine is not running")
}
@@ -293,10 +293,7 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
}
defer authClient.Close()
// 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)
a := &Auth{ctx: ctx, config: cfg}
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)

View File

@@ -1,336 +0,0 @@
package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"github.com/netbirdio/netbird/client/proto"
)
// agentNetworkAuthToken is the placeholder credential exported for
// AI-tool CLIs: the Agent Network proxy authenticates callers by tunnel
// peer and injects the real upstream credentials itself, so the
// client-side token only needs to satisfy the tool's non-empty check.
const agentNetworkAuthToken = "netbird"
var (
agentNetworkProviderFlag string
agentNetworkModelFlag string
agentNetworkJSONFlag bool
)
var agentNetworkCmd = &cobra.Command{
Use: "agent-network",
Short: "Show the Agent Network setup available to this peer",
Long: `Commands to inspect the Agent Network (AI provider proxy) setup this peer's groups authorize:
the proxy endpoint, the reachable providers, and the allowed models.`,
}
var agentNetworkLsCmd = &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List the Agent Network endpoint, providers, and allowed models",
Example: " netbird agent-network ls",
RunE: agentNetworkLs,
}
var agentNetworkEnvCmd = &cobra.Command{
Use: "env",
Short: "Print shell export lines that point AI tools at the Agent Network",
Long: `Print POSIX shell export lines that configure AI tools to use the Agent Network proxy.
The variables depend on the provider's API shape — Anthropic API, AWS Bedrock, Google Vertex AI,
and OpenAI-compatible providers each get the environment their tools expect (for Claude Code,
following its LLM-gateway configuration). Apply them to the current shell with:
eval "$(netbird agent-network env)"
When several providers are authorized, pass --provider to pick one; when several models are
allowed, pass --model to pin one — nothing is ever guessed.`,
Example: " eval \"$(netbird agent-network env)\"\n eval \"$(netbird agent-network env --provider 'Bedrock prod' --model anthropic.claude-sonnet-4-5)\"",
RunE: agentNetworkEnv,
}
func init() {
agentNetworkLsCmd.PersistentFlags().BoolVar(&agentNetworkJSONFlag, "json", false, "output the setup as JSON")
agentNetworkEnvCmd.PersistentFlags().StringVar(&agentNetworkProviderFlag, "provider", "", "provider to configure, by name or catalog id (required when several are authorized)")
agentNetworkEnvCmd.PersistentFlags().StringVar(&agentNetworkModelFlag, "model", "", "model to export (required when several models are allowed)")
}
// fetchAgentNetworkSetup asks the daemon for the caller-scoped Agent
// Network setup. The daemon relays the request to management over its
// existing peer connection, so no elevated permissions are needed.
func fetchAgentNetworkSetup(cmd *cobra.Command) (*proto.GetAgentNetworkSetupResponse, error) {
conn, err := getClient(cmd)
if err != nil {
return nil, err
}
defer conn.Close()
client := proto.NewDaemonServiceClient(conn)
setup, err := client.GetAgentNetworkSetup(cmd.Context(), &proto.GetAgentNetworkSetupRequest{})
if err != nil {
if s, ok := status.FromError(err); ok && s.Code() == codes.Unimplemented {
return nil, fmt.Errorf("the running daemon does not support agent-network commands — update the NetBird daemon and restart the service")
}
return nil, fmt.Errorf("get agent network setup: %v", status.Convert(err).Message())
}
return setup, nil
}
func agentNetworkLs(cmd *cobra.Command, _ []string) error {
setup, err := fetchAgentNetworkSetup(cmd)
if err != nil {
return err
}
if agentNetworkJSONFlag {
out, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(setup)
if err != nil {
return fmt.Errorf("marshal setup: %v", err)
}
cmd.Println(string(out))
return nil
}
if !setup.Configured {
cmd.Println("Agent Network is not available for this peer. Ask your administrator.")
return nil
}
cmd.Printf("Agent Network endpoint: %s\n", setup.Endpoint)
cmd.Println("(reachable while connected to NetBird)")
for _, p := range setup.Providers {
cmd.Println()
cmd.Printf("%s (%s)\n", sanitizeOutput(p.Name), sanitizeOutput(providerFlavorLabel(p)))
switch {
case p.AllModelsAllowed && len(p.Models) == 0:
cmd.Println(" All models allowed")
case p.AllModelsAllowed:
cmd.Println(" All models allowed, including:")
printModels(cmd, p.Models)
default:
cmd.Println(" Allowed models:")
printModels(cmd, p.Models)
}
}
cmd.Println()
cmd.Println("To configure an AI tool in the current shell: eval \"$(netbird agent-network env)\"")
return nil
}
func printModels(cmd *cobra.Command, models []string) {
if len(models) == 0 {
cmd.Println(" (none)")
return
}
for _, m := range models {
cmd.Printf(" %s\n", sanitizeOutput(m))
}
}
func providerFlavorLabel(p *proto.AgentNetworkProvider) string {
if p.ApiFlavor == "" {
return p.CatalogId
}
return fmt.Sprintf("%s · %s-flavor API", p.CatalogId, p.ApiFlavor)
}
func agentNetworkEnv(cmd *cobra.Command, _ []string) error {
setup, err := fetchAgentNetworkSetup(cmd)
if err != nil {
return err
}
if !setup.Configured {
// An answer, not an error: print nothing eval-able and say why on
// stderr so `eval "$(...)"` stays a harmless no-op.
cmd.PrintErrln("Agent Network is not available for this peer. Ask your administrator.")
return nil
}
lines, err := buildAgentNetworkEnv(setup, agentNetworkProviderFlag, agentNetworkModelFlag)
if err != nil {
return err
}
for _, line := range lines {
cmd.Println(line)
}
return nil
}
// buildAgentNetworkEnv renders the export lines for one selected
// provider. Nothing is guessed: an ambiguous provider or model choice
// comes back as comment lines instead of exports, and an invalid
// --provider/--model is an error.
func buildAgentNetworkEnv(setup *proto.GetAgentNetworkSetupResponse, providerFlag, modelFlag string) ([]string, error) {
provider, choiceLines, err := selectAgentNetworkProvider(setup.Providers, providerFlag)
if err != nil {
return nil, err
}
if provider == nil {
return choiceLines, nil
}
model, modelNotes, err := resolveAgentNetworkModel(provider, modelFlag)
if err != nil {
return nil, err
}
var lines []string
switch {
case provider.CatalogId == "bedrock_api":
// Claude Code's Bedrock-format gateway configuration: the proxy
// routes native Bedrock paths and injects the AWS credentials, so
// client-side signing is skipped.
lines = append(lines,
exportLine("CLAUDE_CODE_USE_BEDROCK", "1"),
exportLine("ANTHROPIC_BEDROCK_BASE_URL", setup.Endpoint),
exportLine("CLAUDE_CODE_SKIP_BEDROCK_AUTH", "1"),
)
if model != "" {
lines = append(lines, exportLine("ANTHROPIC_MODEL", model))
}
case provider.CatalogId == "vertex_ai_api":
// Claude Code's Vertex-format gateway configuration. Vertex
// requests carry the GCP project and region in the URL path, which
// the proxy forwards to the upstream — those two values belong to
// the operator's GCP setup and must come from the administrator.
lines = append(lines,
exportLine("CLAUDE_CODE_USE_VERTEX", "1"),
exportLine("ANTHROPIC_VERTEX_BASE_URL", setup.Endpoint),
exportLine("CLAUDE_CODE_SKIP_VERTEX_AUTH", "1"),
)
if model != "" {
lines = append(lines, exportLine("ANTHROPIC_MODEL", model))
}
lines = append(lines,
comment("Vertex requests carry your operator's GCP project and region in the URL."),
comment("Ask your administrator for the values, then export:"),
comment(" export ANTHROPIC_VERTEX_PROJECT_ID=<project>"),
comment(" export CLOUD_ML_REGION=<region>"),
)
case provider.ApiFlavor == "anthropic":
lines = append(lines,
exportLine("ANTHROPIC_BASE_URL", setup.Endpoint),
exportLine("ANTHROPIC_AUTH_TOKEN", agentNetworkAuthToken),
)
if model != "" {
lines = append(lines, exportLine("ANTHROPIC_MODEL", model))
}
case provider.ApiFlavor == "openai":
lines = append(lines,
exportLine("OPENAI_BASE_URL", setup.Endpoint),
exportLine("OPENAI_API_KEY", agentNetworkAuthToken),
)
if model != "" {
lines = append(lines, comment(fmt.Sprintf("Configure your tool to use model %s.", model)))
}
default:
lines = append(lines,
comment(fmt.Sprintf("Provider %s (%s) is dispatched by URL path; no standard environment", provider.Name, provider.CatalogId)),
comment("variables apply. Point your tool at the endpoint below (auth token: netbird):"),
comment(" "+setup.Endpoint),
)
}
for _, note := range modelNotes {
lines = append(lines, comment(note))
}
return lines, nil
}
// selectAgentNetworkProvider picks the provider to configure. An
// explicit --provider matches the operator label or catalog id
// (case-insensitive); with no flag a single authorized provider is
// used, and several come back as comment lines asking for the flag.
func selectAgentNetworkProvider(providers []*proto.AgentNetworkProvider, providerFlag string) (*proto.AgentNetworkProvider, []string, error) {
if providerFlag != "" {
wanted := strings.ToLower(strings.TrimSpace(providerFlag))
for _, p := range providers {
if strings.ToLower(strings.TrimSpace(p.Name)) == wanted || strings.ToLower(p.CatalogId) == wanted {
return p, nil, nil
}
}
names := make([]string, 0, len(providers))
for _, p := range providers {
names = append(names, fmt.Sprintf("%s (%s)", p.Name, p.CatalogId))
}
return nil, nil, fmt.Errorf("provider %q is not authorized for this peer — available: %s", providerFlag, strings.Join(names, ", "))
}
if len(providers) == 1 {
return providers[0], nil, nil
}
lines := []string{comment("Multiple providers are authorized — none configured. Re-run with --provider to pick one:")}
for _, p := range providers {
lines = append(lines, comment(fmt.Sprintf(" netbird agent-network env --provider %q (%s)", p.Name, providerFlavorLabel(p))))
}
return nil, lines, nil
}
// resolveAgentNetworkModel picks the model for the selected provider. A
// model is never guessed: --model wins (validated against the allowed
// set), a single allowed model is used, and anything ambiguous is
// returned as note lines instead.
func resolveAgentNetworkModel(provider *proto.AgentNetworkProvider, modelFlag string) (string, []string, error) {
if modelFlag != "" {
if provider.AllModelsAllowed {
return modelFlag, nil, nil
}
wanted := strings.ToLower(strings.TrimSpace(modelFlag))
for _, m := range provider.Models {
if strings.ToLower(strings.TrimSpace(m)) == wanted {
return modelFlag, nil, nil
}
}
return "", nil, fmt.Errorf("model %q is not allowed on provider %s — run 'netbird agent-network ls' to see the allowed models", modelFlag, provider.Name)
}
if len(provider.Models) == 1 && !provider.AllModelsAllowed {
return provider.Models[0], nil, nil
}
if len(provider.Models) == 0 && provider.AllModelsAllowed {
return "", []string{"Any model is allowed; pass --model to pin one."}, nil
}
notes := []string{"Multiple models are allowed — none exported. Re-run with --model to pin one:"}
for _, m := range provider.Models {
notes = append(notes, " "+m)
}
if provider.AllModelsAllowed {
notes = append(notes, " (any other model the provider serves)")
}
return "", notes, nil
}
func exportLine(name, value string) string {
return fmt.Sprintf("export %s=%s", name, shellQuote(value))
}
func comment(text string) string {
return "# " + sanitizeOutput(text)
}
// shellQuote single-quotes a value for safe use in an eval'd export
// line, escaping embedded single quotes.
func shellQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
// sanitizeOutput strips control characters (including newlines) from
// server-supplied strings so operator-typed values can't break the
// line-oriented output or smuggle lines past a `# ` comment prefix.
func sanitizeOutput(v string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, v)
}

View File

@@ -1,165 +0,0 @@
package cmd
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
func anTestSetup(providers ...*proto.AgentNetworkProvider) *proto.GetAgentNetworkSetupResponse {
return &proto.GetAgentNetworkSetupResponse{
Configured: true,
Endpoint: "https://calm-otter.proxy.example.com",
Providers: providers,
}
}
func TestBuildAgentNetworkEnv_AnthropicSingleModel(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
Models: []string{"claude-sonnet-4-5"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
assert.Equal(t, []string{
"export ANTHROPIC_BASE_URL='https://calm-otter.proxy.example.com'",
"export ANTHROPIC_AUTH_TOKEN='netbird'",
"export ANTHROPIC_MODEL='claude-sonnet-4-5'",
}, lines)
}
func TestBuildAgentNetworkEnv_MultipleModelsBecomeComments(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
assert.Contains(t, lines, "export ANTHROPIC_BASE_URL='https://calm-otter.proxy.example.com'")
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_MODEL=", "no model is ever guessed")
assert.Contains(t, strings.Join(lines, "\n"), "# Multiple models are allowed")
}
func TestBuildAgentNetworkEnv_ModelFlagValidated(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"},
})
lines, err := buildAgentNetworkEnv(setup, "", "Claude-Haiku-4-5")
require.NoError(t, err, "model match is case-insensitive")
assert.Contains(t, lines, "export ANTHROPIC_MODEL='Claude-Haiku-4-5'")
_, err = buildAgentNetworkEnv(setup, "", "gpt-4o")
require.Error(t, err, "a model outside the allowlist is rejected")
}
func TestBuildAgentNetworkEnv_BedrockFlavor(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Bedrock prod", CatalogId: "bedrock_api", ApiFlavor: "",
Models: []string{"anthropic.claude-sonnet-4-5"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
assert.Equal(t, []string{
"export CLAUDE_CODE_USE_BEDROCK='1'",
"export ANTHROPIC_BEDROCK_BASE_URL='https://calm-otter.proxy.example.com'",
"export CLAUDE_CODE_SKIP_BEDROCK_AUTH='1'",
"export ANTHROPIC_MODEL='anthropic.claude-sonnet-4-5'",
}, lines)
}
func TestBuildAgentNetworkEnv_VertexFlavorNotesProjectAndRegion(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Vertex prod", CatalogId: "vertex_ai_api", ApiFlavor: "",
Models: []string{"claude-sonnet-4-5"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
joined := strings.Join(lines, "\n")
assert.Contains(t, lines, "export CLAUDE_CODE_USE_VERTEX='1'")
assert.Contains(t, lines, "export ANTHROPIC_VERTEX_BASE_URL='https://calm-otter.proxy.example.com'")
assert.Contains(t, lines, "export CLAUDE_CODE_SKIP_VERTEX_AUTH='1'")
assert.Contains(t, lines, "export ANTHROPIC_MODEL='claude-sonnet-4-5'")
assert.Contains(t, joined, "ANTHROPIC_VERTEX_PROJECT_ID", "project id must be called out as admin-supplied")
assert.Contains(t, joined, "CLOUD_ML_REGION", "region must be called out as admin-supplied")
}
func TestBuildAgentNetworkEnv_OpenAIFlavor(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "OpenAI prod", CatalogId: "openai_api", ApiFlavor: "openai",
Models: []string{"gpt-5.4"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
assert.Contains(t, lines, "export OPENAI_BASE_URL='https://calm-otter.proxy.example.com'")
assert.Contains(t, lines, "export OPENAI_API_KEY='netbird'")
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_", "openai flavor must not emit anthropic variables")
}
func TestBuildAgentNetworkEnv_MultipleProvidersRequireFlag(t *testing.T) {
anthropic := &proto.AgentNetworkProvider{Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic", Models: []string{"claude-sonnet-4-5"}}
bedrock := &proto.AgentNetworkProvider{Name: "Bedrock prod", CatalogId: "bedrock_api", Models: []string{"anthropic.claude-sonnet-4-5"}}
setup := anTestSetup(anthropic, bedrock)
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
joined := strings.Join(lines, "\n")
assert.NotContains(t, joined, "export ", "ambiguous provider choice must export nothing")
assert.Contains(t, joined, "--provider")
assert.Contains(t, joined, "Bedrock prod")
// Selection by operator label, case-insensitive.
lines, err = buildAgentNetworkEnv(setup, "bedrock prod", "")
require.NoError(t, err)
assert.Contains(t, lines, "export CLAUDE_CODE_USE_BEDROCK='1'")
// Selection by catalog id.
lines, err = buildAgentNetworkEnv(setup, "anthropic_api", "")
require.NoError(t, err)
assert.Contains(t, lines, "export ANTHROPIC_AUTH_TOKEN='netbird'")
// Unknown provider is an error naming the available ones.
_, err = buildAgentNetworkEnv(setup, "vertex", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "Anthropic prod")
}
func TestBuildAgentNetworkEnv_AllModelsAllowed(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
AllModelsAllowed: true, Models: []string{"claude-sonnet-4-5"},
})
// A courtesy-listed single model is still ambiguous when everything is allowed.
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_MODEL=")
// --model passes without allowlist validation.
lines, err = buildAgentNetworkEnv(setup, "", "claude-opus-4-8")
require.NoError(t, err)
assert.Contains(t, lines, "export ANTHROPIC_MODEL='claude-opus-4-8'")
}
func TestBuildAgentNetworkEnv_UnknownFlavorFallsBackToComments(t *testing.T) {
setup := anTestSetup(&proto.AgentNetworkProvider{
Name: "Kimi", CatalogId: "kimi_api", ApiFlavor: "",
Models: []string{"kimi-k3"},
})
lines, err := buildAgentNetworkEnv(setup, "", "")
require.NoError(t, err)
joined := strings.Join(lines, "\n")
assert.NotContains(t, joined, "export ", "unknown API shape must not guess variables")
assert.Contains(t, joined, "https://calm-otter.proxy.example.com")
}

View File

@@ -171,9 +171,6 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(agentNetworkCmd)
agentNetworkCmd.AddCommand(agentNetworkLsCmd)
agentNetworkCmd.AddCommand(agentNetworkEnvCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)

View File

@@ -113,14 +113,11 @@ func (c *ConnectClient) RunOnAndroid(
stateFilePath string,
cacheDir string,
) error {
notifier := tunnelnotifier.New(networkChangeListener, nil)
defer notifier.Close()
// in case of non Android os these variables will be nil
mobileDependency := MobileDependency{
TunAdapter: tunAdapter,
IFaceDiscover: iFaceDiscover,
NetworkChangeListener: notifier,
NetworkChangeListener: networkChangeListener,
HostDNSAddresses: dnsAddresses,
DnsReadyListener: dnsReadyListener,
StateFilePath: stateFilePath,

View File

@@ -51,5 +51,7 @@ func (n *notifier) notify() {
return
}
n.listener.OnNetworkChanged("")
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged("")
}(n.listener)
}

View File

@@ -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,12 +602,6 @@ 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 {
@@ -692,7 +686,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)

View File

@@ -572,7 +572,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
e.stateManager.Start()
dnsServer, err := e.newDnsServer()
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
if err != nil {
return fmt.Errorf("read initial settings: %w", err)
}
dnsServer, err := e.newDnsServer(dnsConfig)
if err != nil {
return fmt.Errorf("create dns server: %w", err)
}
@@ -590,8 +595,10 @@ 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,
@@ -2095,6 +2102,42 @@ 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 {
@@ -2129,7 +2172,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.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
case "ios":
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
if e.config.WgAddr.HasIPv6() {
@@ -2142,7 +2185,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
return err
}
func (e *Engine) newDnsServer() (dns.Server, error) {
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (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
@@ -2154,7 +2197,7 @@ func (e *Engine) newDnsServer() (dns.Server, error) {
e.ctx,
e.wgInterface,
e.mobileDep.HostDNSAddresses,
nbdns.Config{},
*dnsConfig,
e.mobileDep.NetworkChangeListener,
e.statusRecorder,
e.config.DisableDNS,
@@ -2200,19 +2243,6 @@ func (e *Engine) GetExposeManager() *expose.Manager {
return e.exposeManager
}
// GetAgentNetworkSetup asks the management server for the Agent Network
// connection info this peer's groups authorize, over the engine's
// existing management connection.
func (e *Engine) GetAgentNetworkSetup(ctx context.Context) (*mgmProto.AgentNetworkSetupResponse, error) {
e.syncMsgMux.Lock()
mgmClient := e.mgmClient
e.syncMsgMux.Unlock()
if mgmClient == nil {
return nil, errors.New("management client not available")
}
return mgmClient.GetAgentNetworkSetup(ctx)
}
// IsBlockInbound returns whether inbound connections are blocked.
func (e *Engine) IsBlockInbound() bool {
return e.config.BlockInbound

View File

@@ -1,20 +0,0 @@
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
}

View File

@@ -45,35 +45,12 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
return &state, nil
}
// SetProfileState writes the state file of the profile identified by id. Prefer
// it over SetActiveProfileState whenever the caller knows which profile the data
// belongs to: an SSO login spans seconds of user interaction, and the active
// profile can change during it, which would file the account email under
// whichever profile happened to be active when the flow returned.
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
if id == "" {
return fmt.Errorf("empty profile ID")
}
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
}
// SetActiveProfileState writes the state file of whichever profile is active at
// call time. Use SetProfileState when the target profile is known.
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
activeProf, err := pm.GetActiveProfile()
if err != nil {
if errors.Is(err, ErrNoActiveProfile) {
@@ -82,7 +59,18 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
return pm.SetProfileState(activeProf.ID, state)
id := activeProf.ID
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid active profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
}
// RemoveProfileState deletes the per-profile state file (which holds the

View File

@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
// internalDnatFw checks if the firewall supports internal DNAT
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
if d.firewall == nil || runtime.GOOS != "android" {
return nil, false
}
fw, ok := d.firewall.(internalDNATer)

View File

@@ -8,13 +8,14 @@ import (
"net/netip"
"net/url"
"runtime"
"sort"
"slices"
"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"
@@ -61,7 +62,7 @@ type Manager interface {
GetActiveClientRoutes() route.HAMap
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
SetRouteChangeListener(listener listener.NetworkChangeListener)
CurrentRouteRange() []string
InitialRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
@@ -75,8 +76,10 @@ 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
@@ -146,12 +149,45 @@ 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)
func (m *DefaultManager) enableFakeIPRoutes() {
m.fakeIPManager = fakeip.NewManager()
m.notifier.NotifyRouteChange()
routesForComparison := slices.Clone(cr)
if config.DNSFeatureFlag {
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,
}
cr = append(cr, fakeIPRoute, fakeIPv6Route)
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
}
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
}
func (m *DefaultManager) setupRefCounters(useNoop bool) {
@@ -428,9 +464,6 @@ func (m *DefaultManager) UpdateRoutes(
var merr *multierror.Error
if !m.disableClientRoutes {
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
m.enableFakeIPRoutes()
}
// Update route selector based on management server's isSelected status
m.updateRouteSelectorFromManagement(clientRoutes)
@@ -467,32 +500,9 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
m.notifier.SetListener(listener)
}
// 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
// InitialRouteRange return the list of initial routes. It used by mobile systems
func (m *DefaultManager) InitialRouteRange() []string {
return m.notifier.GetInitialRouteRanges()
}
// GetRouteSelector returns the route selector
@@ -690,6 +700,16 @@ 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

View File

@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
return nil
}
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
func (m *MockManager) CurrentRouteRange() []string {
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
func (m *MockManager) InitialRouteRange() []string {
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"net/netip"
"slices"
"sort"
"strings"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -13,15 +14,12 @@ import (
)
type Notifier struct {
mu 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.
initialRoutes []*route.Route
currentRoutes []*route.Route
fakeIPRoutes []*route.Route
listener listener.NetworkChangeListener
listener listener.NetworkChangeListener
listenerMux sync.Mutex
}
func NewNotifier() *Notifier {
@@ -29,15 +27,20 @@ func NewNotifier() *Notifier {
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.mu.Lock()
defer n.mu.Unlock()
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
n.listener = listener
}
func (n *Notifier) NotifyRouteChange() {
n.mu.Lock()
defer n.mu.Unlock()
n.notifyLocked()
// 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
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -51,32 +54,46 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
}
}
n.mu.Lock()
defer n.mu.Unlock()
if !hasRouteDiff(n.currentRoutes, newRoutes) {
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
return
}
n.currentRoutes = newRoutes
n.notifyLocked()
n.notify()
}
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
// Not used on Android
}
func (n *Notifier) notifyLocked() {
func (n *Notifier) notify() {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
if n.listener == nil {
return
}
n.listener.OnNetworkChanged("")
allRoutes := slices.Clone(n.currentRoutes)
allRoutes = append(allRoutes, n.fakeIPRoutes...)
routeStrings := n.routesToStrings(allRoutes)
sort.Strings(routeStrings)
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged(strings.Join(routeStrings, ","))
}(n.listener)
}
func (n *Notifier) Close() {
// unused
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 routesToStrings(routes []*route.Route) []string {
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
@@ -84,10 +101,25 @@ func routesToStrings(routes []*route.Route) []string {
return nets
}
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) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
slices.SortFunc(a, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
slices.SortFunc(b, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
return x.NetString() == y.NetString()
})
}
func (n *Notifier) GetInitialRouteRanges() []string {
initialStrings := n.routesToStrings(n.initialRoutes)
sort.Strings(initialStrings)
return initialStrings
}
func (n *Notifier) Close() {
// unused
}

View File

@@ -29,7 +29,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listener = listener
}
func (n *Notifier) NotifyRouteChange() {
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// iOS doesn't care about initial routes
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
// Not used on iOS
}

View File

@@ -19,7 +19,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
// Not used on non-mobile platforms
}
func (n *Notifier) NotifyRouteChange() {
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
// Not used on non-mobile platforms
}
@@ -31,6 +35,10 @@ 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
}

View File

@@ -98,44 +98,47 @@ func (u *Installer) startDaemon(daemonFolder string) error {
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)
username, err := consoleUser()
// Get the current console user
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
output, err := cmd.Output()
if err != nil {
return err
return fmt.Errorf("failed to get console user: %w", err)
}
username := strings.TrimSpace(string(output))
if username == "" || username == "root" {
return fmt.Errorf("no active user session found")
}
log.Infof("starting UI for user: %s", username)
// Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("lookup user %s: %w", username, err)
return fmt.Errorf("failed to lookup user %s: %w", username, err)
}
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
// Start the UI process as the console user using launchctl
// This ensures the app runs in the user's context with proper GUI access
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
// Set the user's home directory for proper macOS app behavior
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
if err := launchCmd.Run(); err != nil {
return fmt.Errorf("run UI launch: %w", err)
if err := launchCmd.Start(); err != nil {
return fmt.Errorf("failed to start UI process: %w", err)
}
// Release the process so it can run independently
if err := launchCmd.Process.Release(); err != nil {
log.Warnf("failed to release UI process: %v", err)
}
log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
func consoleUser() (string, error) {
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return "", fmt.Errorf("get console user: %w", err)
}
username := strings.TrimSpace(string(output))
switch username {
case "", "root", "loginwindow", "_mbsetupuser":
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
}
return username, nil
}
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
log.Infof("installing pkg file: %s", path)

View File

@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
@@ -84,7 +85,14 @@ type Client struct {
stateMu sync.RWMutex
connectClient *internal.ConnectClient
config *profilemanager.Config
// config holds the active configuration once Run has loaded it. Consumed by
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
config *profilemanager.Config
// sshJWTCache keeps the SSH JWT token between reconnects so the user is not
// forced through the browser OAuth flow on every session. Lives on Client
// (not SSHClient) because the app creates a new SSHClient per session.
sshJWTCache *jwtcache.Cache
}
// NewClient instantiate a new Client
@@ -101,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
sshJWTCache: jwtcache.New(),
}
}
@@ -175,6 +184,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
ctx = internal.CtxInitState(ctx)
c.onHostDnsFn = func([]string) {}
cfg.WgIface = interfaceName
c.config = cfg
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, connectClient)
@@ -697,6 +707,13 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClien
return c.config, c.connectClient
}
// sshState returns the active config and the running connect client for the
// in-app SSH client. Both are nil until Run has loaded the config and started
// the tunnel.
func (c *Client) sshState() (*profilemanager.Config, *internal.ConnectClient) {
return c.stateSnapshot()
}
func formatDuration(d time.Duration) string {
ds := d.String()
dotIndex := strings.Index(ds, ".")

View File

@@ -0,0 +1,447 @@
//go:build ios
package NetBirdSDK
import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"sync"
"time"
log "github.com/sirupsen/logrus"
gossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/ssh/detection"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
)
const (
sshDialTimeout = 30 * time.Second
sshDetectionTimeout = 5 * time.Second
)
// SSHTerminalListener receives SSH session events. It is implemented in Swift.
//
// All callbacks are invoked from goroutines and may run concurrently with each
// other; the implementation must be safe to call from any thread.
type SSHTerminalListener interface {
OnConnected()
OnData(data []byte)
OnClose(reason string)
OnError(message string)
}
// SSHClient is a NetBird-aware SSH client exposed to Swift via gomobile.
//
// It dials through the running NetBird tunnel and runs a standard SSH session
// on top with PTY enabled. Host-key verification uses the NetBird-provided
// peer SSH host keys, identical to the desktop client.
type SSHClient struct {
nb *Client
mu sync.Mutex
listener SSHTerminalListener
urlOpener URLOpener
sshClient *gossh.Client
session *gossh.Session
stdin io.WriteCloser
closed bool
}
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
func NewSSHClient(c *Client) *SSHClient {
return &SSHClient{nb: c}
}
// SetListener registers the Swift listener. Must be called before Connect to
// receive any events.
func (s *SSHClient) SetListener(l SSHTerminalListener) {
s.mu.Lock()
s.listener = l
s.mu.Unlock()
}
// SetURLOpener registers the Swift URL opener used to display the device-code
// authorization page in an in-app browser when the target peer requires JWT
// authentication. Must be set before Connect to be effective.
func (s *SSHClient) SetURLOpener(opener URLOpener) {
s.mu.Lock()
s.urlOpener = opener
s.mu.Unlock()
}
// Connect dials the SSH server through the NetBird tunnel and performs the
// SSH handshake. It auto-detects the server type via SSH banner inspection
// and selects the appropriate authentication path:
//
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
// flow, opens the verification URL through the registered URLOpener, and
// uses the resulting token as the SSH password. Host-key verification
// uses the NetBird peer registry.
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
// private key. Host-key verification uses the NetBird peer registry.
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
// first (so a user-installed NetBird public key works), then falls back
// to the supplied password if non-empty. Host-key verification is
// disabled (TOFU pending).
//
// The password parameter is only consulted for regular SSH servers.
func (s *SSHClient) Connect(host string, port int, user, password string) error {
cfg, cc := s.nb.sshState()
if cc == nil {
return errors.New("netbird client not running")
}
if cfg == nil {
return errors.New("netbird config not loaded")
}
engine := cc.Engine()
if engine == nil {
return errors.New("netbird engine not available")
}
serverType := detectServerType(host, port)
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
if err != nil {
return err
}
clientConfig := &gossh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: sshDialTimeout,
}
return s.dialAndHandshake(host, port, clientConfig)
}
// StartSession requests a PTY and starts an interactive shell. Output from
// the session is forwarded to the listener via OnData.
func (s *SSHClient) StartSession(cols, rows int) error {
log.Debugf("SSH: starting session %dx%d", cols, rows)
s.mu.Lock()
sshClient := s.sshClient
s.mu.Unlock()
if sshClient == nil {
return errors.New("ssh client not connected")
}
session, err := sshClient.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
modes := gossh.TerminalModes{
gossh.ECHO: 1,
gossh.TTY_OP_ISPEED: 14400,
gossh.TTY_OP_OSPEED: 14400,
gossh.VINTR: 3,
gossh.VQUIT: 28,
gossh.VERASE: 127,
}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
closeQuiet(session, "session after pty error")
return fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
closeQuiet(session, "session after stdin error")
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
closeQuiet(session, "session after stdout error")
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
closeQuiet(session, "session after stderr error")
return fmt.Errorf("stderr pipe: %w", err)
}
if err := session.Shell(); err != nil {
closeQuiet(session, "session after shell error")
return fmt.Errorf("start shell: %w", err)
}
s.mu.Lock()
s.session = session
s.stdin = stdin
s.mu.Unlock()
go s.readLoop(stdout, "stdout")
go s.readLoop(stderr, "stderr")
log.Debug("SSH: session started, shell running")
return nil
}
// Write sends data to the SSH session stdin.
func (s *SSHClient) Write(data []byte) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return errors.New("ssh session not started")
}
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
// Resize updates the PTY window size.
func (s *SSHClient) Resize(cols, rows int) error {
s.mu.Lock()
session := s.session
s.mu.Unlock()
if session == nil {
return errors.New("ssh session not started")
}
return session.WindowChange(rows, cols)
}
// Close terminates the SSH session and underlying connection. Safe to call
// multiple times.
func (s *SSHClient) Close() error {
s.mu.Lock()
sshClient := s.sshClient
session := s.session
stdin := s.stdin
s.sshClient = nil
s.session = nil
s.stdin = nil
s.mu.Unlock()
if stdin != nil {
if err := stdin.Close(); err != nil {
log.Debugf("ssh: stdin close: %v", err)
}
}
if session != nil {
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: session close: %v", err)
}
}
var firstErr error
if sshClient != nil {
if err := sshClient.Close(); err != nil {
firstErr = err
}
}
s.notifyClose("closed by client")
return firstErr
}
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
switch serverType {
case detection.ServerTypeNetBirdJWT:
token, err := s.requestJWTToken(cfg)
if err != nil {
return nil, nil, fmt.Errorf("jwt: %w", err)
}
auths := []gossh.AuthMethod{gossh.Password(token)}
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
case detection.ServerTypeNetBirdNoJWT:
if cfg.SSHKey == "" {
return nil, nil, errors.New("no NetBird SSH key available")
}
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
if err != nil {
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
}
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
default: // regular SSH
var auths []gossh.AuthMethod
if cfg.SSHKey != "" {
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
auths = append(auths, gossh.PublicKeys(signer))
} else {
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
}
}
if password != "" {
pw := password
auths = append(auths, gossh.Password(pw))
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = pw
}
return answers, nil
}))
}
if len(auths) == 0 {
return nil, nil, errors.New("no auth method available: provide a password or configure NetBird SSH key")
}
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
}
}
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
// Reuse a cached token so the user is not forced through the browser OAuth
// flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the
// daemon's cache; unset/0 disables caching.
if token, ok := s.nb.sshJWTCache.Get(); ok {
log.Debug("SSH: reusing cached JWT token")
return token, nil
}
s.mu.Lock()
urlOpener := s.urlOpener
s.mu.Unlock()
if urlOpener == nil {
return "", errors.New("URL opener not configured for JWT auth")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}
flowInfo, err := flow.RequestAuthInfo(ctx)
if err != nil {
return "", fmt.Errorf("request auth info: %w", err)
}
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
return "", fmt.Errorf("wait for token: %w", err)
}
token := tokenInfo.GetTokenToUse()
if token == "" {
return "", errors.New("empty token returned by IdP")
}
if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 {
s.nb.sshJWTCache.Store(token, ttl)
}
return token, nil
}
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
defer cancel()
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
if err != nil {
if cerr := conn.Close(); cerr != nil {
log.Debugf("ssh: close after handshake error: %v", cerr)
}
return fmt.Errorf("ssh handshake: %w", err)
}
s.mu.Lock()
s.sshClient = gossh.NewClient(sshConn, chans, reqs)
listener := s.listener
s.mu.Unlock()
log.Infof("SSH: connected to %s", addr)
if listener != nil {
listener.OnConnected()
}
return nil
}
func (s *SSHClient) readLoop(r io.Reader, name string) {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
chunk := make([]byte, n)
copy(chunk, buf[:n])
listener.OnData(chunk)
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
log.Debugf("ssh %s read: %v", name, err)
}
s.notifyClose(err.Error())
return
}
}
}
func (s *SSHClient) notifyClose(reason string) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnClose(reason)
}
}
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
type engineHostKeyVerifier struct {
engine *internal.Engine
}
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
if !found {
return nbssh.ErrPeerNotFound
}
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
}
func detectServerType(host string, port int) detection.ServerType {
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
defer cancel()
dialer := &net.Dialer{}
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
if err != nil {
log.Debugf("ssh: server detection for %s:%d failed: %v (assuming regular SSH)", host, port, err)
return detection.ServerTypeRegular
}
return serverType
}
func closeQuiet(c io.Closer, label string) {
if c == nil {
return
}
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: close %s: %v", label, err)
}
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v7.34.1
// protoc v6.33.1
// source: daemon.proto
package proto
@@ -6941,190 +6941,6 @@ func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{106}
}
type GetAgentNetworkSetupRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetAgentNetworkSetupRequest) Reset() {
*x = GetAgentNetworkSetupRequest{}
mi := &file_daemon_proto_msgTypes[107]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetAgentNetworkSetupRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetAgentNetworkSetupRequest) ProtoMessage() {}
func (x *GetAgentNetworkSetupRequest) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[107]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetAgentNetworkSetupRequest.ProtoReflect.Descriptor instead.
func (*GetAgentNetworkSetupRequest) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{107}
}
type GetAgentNetworkSetupResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// configured is false when the account has no Agent Network set up or
// this peer's groups authorize none of it.
Configured bool `protobuf:"varint,1,opt,name=configured,proto3" json:"configured,omitempty"`
// endpoint is the account's proxy base URL, reachable over the NetBird
// tunnel only. Empty when configured is false.
Endpoint string `protobuf:"bytes,2,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
Providers []*AgentNetworkProvider `protobuf:"bytes,3,rep,name=providers,proto3" json:"providers,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetAgentNetworkSetupResponse) Reset() {
*x = GetAgentNetworkSetupResponse{}
mi := &file_daemon_proto_msgTypes[108]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetAgentNetworkSetupResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetAgentNetworkSetupResponse) ProtoMessage() {}
func (x *GetAgentNetworkSetupResponse) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[108]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetAgentNetworkSetupResponse.ProtoReflect.Descriptor instead.
func (*GetAgentNetworkSetupResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{108}
}
func (x *GetAgentNetworkSetupResponse) GetConfigured() bool {
if x != nil {
return x.Configured
}
return false
}
func (x *GetAgentNetworkSetupResponse) GetEndpoint() string {
if x != nil {
return x.Endpoint
}
return ""
}
func (x *GetAgentNetworkSetupResponse) GetProviders() []*AgentNetworkProvider {
if x != nil {
return x.Providers
}
return nil
}
type AgentNetworkProvider struct {
state protoimpl.MessageState `protogen:"open.v1"`
// name is the operator-assigned provider label, e.g. "Bedrock prod".
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// catalog_id names the provider type, e.g. "anthropic_api", "bedrock_api".
CatalogId string `protobuf:"bytes,2,opt,name=catalog_id,json=catalogId,proto3" json:"catalog_id,omitempty"`
// api_flavor is the request-body shape the provider speaks ("anthropic",
// "openai"); empty when the proxy dispatches it by URL path instead.
ApiFlavor string `protobuf:"bytes,3,opt,name=api_flavor,json=apiFlavor,proto3" json:"api_flavor,omitempty"`
// all_models_allowed is true when no model allowlist restricts this
// provider for the caller.
AllModelsAllowed bool `protobuf:"varint,4,opt,name=all_models_allowed,json=allModelsAllowed,proto3" json:"all_models_allowed,omitempty"`
// models is the effective model allowlist (or the declared/catalog models
// when all_models_allowed is true).
Models []string `protobuf:"bytes,5,rep,name=models,proto3" json:"models,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AgentNetworkProvider) Reset() {
*x = AgentNetworkProvider{}
mi := &file_daemon_proto_msgTypes[109]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AgentNetworkProvider) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentNetworkProvider) ProtoMessage() {}
func (x *AgentNetworkProvider) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[109]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AgentNetworkProvider.ProtoReflect.Descriptor instead.
func (*AgentNetworkProvider) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{109}
}
func (x *AgentNetworkProvider) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *AgentNetworkProvider) GetCatalogId() string {
if x != nil {
return x.CatalogId
}
return ""
}
func (x *AgentNetworkProvider) GetApiFlavor() string {
if x != nil {
return x.ApiFlavor
}
return ""
}
func (x *AgentNetworkProvider) GetAllModelsAllowed() bool {
if x != nil {
return x.AllModelsAllowed
}
return false
}
func (x *AgentNetworkProvider) GetModels() []string {
if x != nil {
return x.Models
}
return nil
}
type PortInfo_Range struct {
state protoimpl.MessageState `protogen:"open.v1"`
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
@@ -7135,7 +6951,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
mi := &file_daemon_proto_msgTypes[111]
mi := &file_daemon_proto_msgTypes[108]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -7147,7 +6963,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[111]
mi := &file_daemon_proto_msgTypes[108]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7761,22 +7577,7 @@ const file_daemon_proto_rawDesc = "" +
"\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" +
"\x1aStartBundleCaptureResponse\"\x1a\n" +
"\x18StopBundleCaptureRequest\"\x1b\n" +
"\x19StopBundleCaptureResponse\"\x1d\n" +
"\x1bGetAgentNetworkSetupRequest\"\x96\x01\n" +
"\x1cGetAgentNetworkSetupResponse\x12\x1e\n" +
"\n" +
"configured\x18\x01 \x01(\bR\n" +
"configured\x12\x1a\n" +
"\bendpoint\x18\x02 \x01(\tR\bendpoint\x12:\n" +
"\tproviders\x18\x03 \x03(\v2\x1c.daemon.AgentNetworkProviderR\tproviders\"\xae\x01\n" +
"\x14AgentNetworkProvider\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" +
"\n" +
"catalog_id\x18\x02 \x01(\tR\tcatalogId\x12\x1d\n" +
"\n" +
"api_flavor\x18\x03 \x01(\tR\tapiFlavor\x12,\n" +
"\x12all_models_allowed\x18\x04 \x01(\bR\x10allModelsAllowed\x12\x16\n" +
"\x06models\x18\x05 \x03(\tR\x06models*b\n" +
"\x19StopBundleCaptureResponse*b\n" +
"\bLogLevel\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" +
"\x05PANIC\x10\x01\x12\t\n" +
@@ -7794,7 +7595,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
"EXPOSE_TLS\x10\x042\x88\x1d\n" +
"EXPOSE_TLS\x10\x042\xa3\x1c\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -7844,8 +7645,7 @@ const file_daemon_proto_rawDesc = "" +
"\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" +
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" +
"\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" +
"\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00\x12c\n" +
"\x14GetAgentNetworkSetup\x12#.daemon.GetAgentNetworkSetupRequest\x1a$.daemon.GetAgentNetworkSetupResponse\"\x00B\bZ\x06/protob\x06proto3"
"\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3"
var (
file_daemon_proto_rawDescOnce sync.Once
@@ -7860,7 +7660,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 113)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -7973,22 +7773,19 @@ var file_daemon_proto_goTypes = []any{
(*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse
(*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest
(*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse
(*GetAgentNetworkSetupRequest)(nil), // 111: daemon.GetAgentNetworkSetupRequest
(*GetAgentNetworkSetupResponse)(nil), // 112: daemon.GetAgentNetworkSetupResponse
(*AgentNetworkProvider)(nil), // 113: daemon.AgentNetworkProvider
nil, // 114: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 115: daemon.PortInfo.Range
nil, // 116: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 117: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 118: google.protobuf.Timestamp
nil, // 111: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
nil, // 113: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 114: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
117, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
118, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
118, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
118, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
117, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -7999,8 +7796,8 @@ var file_daemon_proto_depIdxs = []int32{
57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
114, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
115, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
@@ -8011,117 +7808,114 @@ var file_daemon_proto_depIdxs = []int32{
54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
118, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
116, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
117, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
118, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
117, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
117, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
113, // 38: daemon.GetAgentNetworkSetupResponse.providers:type_name -> daemon.AgentNetworkProvider
30, // 39: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 40: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 41: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 42: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 43: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 44: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 45: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 46: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 47: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 48: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 49: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 50: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 51: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 52: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 53: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 54: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 55: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 56: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 57: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 58: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 59: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 60: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 61: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 62: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 63: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 64: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 65: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 66: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 67: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 68: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 69: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
111, // 86: daemon.DaemonService.GetAgentNetworkSetup:input_type -> daemon.GetAgentNetworkSetupRequest
6, // 87: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 88: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 89: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 90: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 91: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 92: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 93: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 94: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 95: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 96: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 97: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 98: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 99: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 100: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 101: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 102: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 103: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 104: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 105: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 106: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 107: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 108: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 109: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 110: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 111: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 112: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 113: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 114: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 115: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 116: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 117: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 118: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 119: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 120: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 121: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 122: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 123: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 124: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 125: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 126: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 127: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 128: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 129: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 130: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 131: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 132: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
112, // 133: daemon.DaemonService.GetAgentNetworkSetup:output_type -> daemon.GetAgentNetworkSetupResponse
87, // [87:134] is the sub-list for method output_type
40, // [40:87] is the sub-list for method input_type
40, // [40:40] is the sub-list for extension type_name
40, // [40:40] is the sub-list for extension extendee
0, // [0:40] is the sub-list for field type_name
114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
85, // [85:131] is the sub-list for method output_type
39, // [39:85] is the sub-list for method input_type
39, // [39:39] is the sub-list for extension type_name
39, // [39:39] is the sub-list for extension extendee
0, // [0:39] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -8153,7 +7947,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
NumMessages: 113,
NumMessages: 110,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -1123,30 +1123,6 @@ func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler r
return msg, metadata, err
}
func request_DaemonService_GetAgentNetworkSetup_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetAgentNetworkSetupRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetAgentNetworkSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_GetAgentNetworkSetup_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetAgentNetworkSetupRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetAgentNetworkSetup(ctx, &protoReq)
return msg, metadata, err
}
// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux".
// UnaryRPC :call DaemonServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@@ -2021,26 +1997,6 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_GetAgentNetworkSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetAgentNetworkSetup", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetAgentNetworkSetup"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_GetAgentNetworkSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_GetAgentNetworkSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@@ -2863,23 +2819,6 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_GetAgentNetworkSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetAgentNetworkSetup", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetAgentNetworkSetup"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_GetAgentNetworkSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_GetAgentNetworkSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@@ -2930,7 +2869,6 @@ var (
pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, ""))
pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, ""))
pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, ""))
pattern_DaemonService_GetAgentNetworkSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetAgentNetworkSetup"}, ""))
)
var (
@@ -2980,5 +2918,4 @@ var (
forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetAgentNetworkSetup_0 = runtime.ForwardResponseMessage
)

View File

@@ -156,12 +156,6 @@ service DaemonService {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {}
// GetAgentNetworkSetup returns the Agent Network connection info this
// peer's groups authorize: proxy endpoint plus effective providers and
// models. The daemon relays the request to management over its existing
// peer connection, so unprivileged CLI callers get a caller-scoped answer.
rpc GetAgentNetworkSetup(GetAgentNetworkSetupRequest) returns (GetAgentNetworkSetupResponse) {}
}
@@ -1056,31 +1050,3 @@ message StartBundleCaptureRequest {
message StartBundleCaptureResponse {}
message StopBundleCaptureRequest {}
message StopBundleCaptureResponse {}
message GetAgentNetworkSetupRequest {}
message GetAgentNetworkSetupResponse {
// configured is false when the account has no Agent Network set up or
// this peer's groups authorize none of it.
bool configured = 1;
// endpoint is the account's proxy base URL, reachable over the NetBird
// tunnel only. Empty when configured is false.
string endpoint = 2;
repeated AgentNetworkProvider providers = 3;
}
message AgentNetworkProvider {
// name is the operator-assigned provider label, e.g. "Bedrock prod".
string name = 1;
// catalog_id names the provider type, e.g. "anthropic_api", "bedrock_api".
string catalog_id = 2;
// api_flavor is the request-body shape the provider speaks ("anthropic",
// "openai"); empty when the proxy dispatches it by URL path instead.
string api_flavor = 3;
// all_models_allowed is true when no model allowlist restricts this
// provider for the caller.
bool all_models_allowed = 4;
// models is the effective model allowlist (or the declared/catalog models
// when all_models_allowed is true).
repeated string models = 5;
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// - protoc v6.33.1
// source: daemon.proto
package proto
@@ -65,7 +65,6 @@ const (
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
DaemonService_GetAgentNetworkSetup_FullMethodName = "/daemon.DaemonService/GetAgentNetworkSetup"
)
// DaemonServiceClient is the client API for DaemonService service.
@@ -172,11 +171,6 @@ type DaemonServiceClient interface {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error)
// GetAgentNetworkSetup returns the Agent Network connection info this
// peer's groups authorize: proxy endpoint plus effective providers and
// models. The daemon relays the request to management over its existing
// peer connection, so unprivileged CLI callers get a caller-scoped answer.
GetAgentNetworkSetup(ctx context.Context, in *GetAgentNetworkSetupRequest, opts ...grpc.CallOption) (*GetAgentNetworkSetupResponse, error)
}
type daemonServiceClient struct {
@@ -683,16 +677,6 @@ func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReady
return out, nil
}
func (c *daemonServiceClient) GetAgentNetworkSetup(ctx context.Context, in *GetAgentNetworkSetupRequest, opts ...grpc.CallOption) (*GetAgentNetworkSetupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetAgentNetworkSetupResponse)
err := c.cc.Invoke(ctx, DaemonService_GetAgentNetworkSetup_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility.
@@ -797,11 +781,6 @@ type DaemonServiceServer interface {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error)
// GetAgentNetworkSetup returns the Agent Network connection info this
// peer's groups authorize: proxy endpoint plus effective providers and
// models. The daemon relays the request to management over its existing
// peer connection, so unprivileged CLI callers get a caller-scoped answer.
GetAgentNetworkSetup(context.Context, *GetAgentNetworkSetupRequest) (*GetAgentNetworkSetupResponse, error)
mustEmbedUnimplementedDaemonServiceServer()
}
@@ -950,9 +929,6 @@ func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grp
func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
}
func (UnimplementedDaemonServiceServer) GetAgentNetworkSetup(context.Context, *GetAgentNetworkSetupRequest) (*GetAgentNetworkSetupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetAgentNetworkSetup not implemented")
}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
@@ -1774,24 +1750,6 @@ func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
func _DaemonService_GetAgentNetworkSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetAgentNetworkSetupRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).GetAgentNetworkSetup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_GetAgentNetworkSetup_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).GetAgentNetworkSetup(ctx, req.(*GetAgentNetworkSetupRequest))
}
return interceptor(ctx, in, info, handler)
}
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1967,10 +1925,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "WailsUIReady",
Handler: _DaemonService_WailsUIReady_Handler,
},
{
MethodName: "GetAgentNetworkSetup",
Handler: _DaemonService_GetAgentNetworkSetup_Handler,
},
},
Streams: []grpc.StreamDesc{
{

View File

@@ -1,59 +0,0 @@
package server
import (
"context"
"time"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// GetAgentNetworkSetup relays the peer's Agent Network setup request to the
// management server over the engine's existing connection. Running through
// the daemon keeps the WireGuard key inside the daemon — unprivileged CLI
// callers get a caller-scoped answer without reading the profile config.
func (s *Server) GetAgentNetworkSetup(ctx context.Context, _ *proto.GetAgentNetworkSetupRequest) (*proto.GetAgentNetworkSetupResponse, error) {
s.mutex.Lock()
clientRunning := s.clientRunning
connectClient := s.connectClient
s.mutex.Unlock()
if !clientRunning || connectClient == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running, run 'netbird up' first")
}
engine := connectClient.Engine()
if engine == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine not initialized")
}
setupCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
setup, err := engine.GetAgentNetworkSetup(setupCtx)
if err != nil {
return nil, gstatus.Errorf(codes.Internal, "get agent network setup: %v", err)
}
return toDaemonAgentNetworkSetup(setup), nil
}
func toDaemonAgentNetworkSetup(setup *mgmProto.AgentNetworkSetupResponse) *proto.GetAgentNetworkSetupResponse {
resp := &proto.GetAgentNetworkSetupResponse{
Configured: setup.Configured,
Endpoint: setup.Endpoint,
Providers: make([]*proto.AgentNetworkProvider, 0, len(setup.Providers)),
}
for _, p := range setup.Providers {
resp.Providers = append(resp.Providers, &proto.AgentNetworkProvider{
Name: p.Name,
CatalogId: p.CatalogId,
ApiFlavor: p.ApiFlavor,
AllModelsAllowed: p.AllModelsAllowed,
Models: p.Models,
})
}
return resp
}

View File

@@ -1,79 +0,0 @@
package server
import (
"sync"
"time"
"github.com/awnumar/memguard"
log "github.com/sirupsen/logrus"
)
type jwtCache struct {
mu sync.RWMutex
enclave *memguard.Enclave
expiresAt time.Time
timer *time.Timer
maxTokenSize int
}
func newJWTCache() *jwtCache {
return &jwtCache{
maxTokenSize: 8192,
}
}
func (c *jwtCache) store(token string, maxAge time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.cleanup()
if c.timer != nil {
c.timer.Stop()
}
tokenBytes := []byte(token)
c.enclave = memguard.NewEnclave(tokenBytes)
c.expiresAt = time.Now().Add(maxAge)
var timer *time.Timer
timer = time.AfterFunc(maxAge, func() {
c.mu.Lock()
defer c.mu.Unlock()
if c.timer != timer {
return
}
c.cleanup()
c.timer = nil
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
})
c.timer = timer
}
func (c *jwtCache) get() (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.enclave == nil || time.Now().After(c.expiresAt) {
return "", false
}
buffer, err := c.enclave.Open()
if err != nil {
log.Debugf("Failed to open JWT token enclave: %v", err)
return "", false
}
defer buffer.Destroy()
token := string(buffer.Bytes())
return token, true
}
// cleanup destroys the secure enclave, must be called with lock held
func (c *jwtCache) cleanup() {
if c.enclave != nil {
c.enclave = nil
}
c.expiresAt = time.Time{}
}

View File

@@ -1,89 +0,0 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// A login that never reached Management is not a decision about the peer's
// credentials, so it must come back as a retryable error rather than an SSO
// prompt: the user cannot finish a browser login while Management is down, and
// the CLI's own backoff resolves the outage on its own once the daemon reports
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
// because Management happened to be restarting when the daemon dialed it.
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
attempts++
return internal.StatusLoginFailed, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
require.Nil(t, resp, "a failed login must not answer with a login response")
require.Equal(t, 1, attempts)
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusLoginFailed, status,
"a peer that could not reach Management is not waiting on a login")
}
// The counterpart: Management refusing the peer's credentials is a decision, and
// the SSO flow still has to start for it. The profile carries an unusable
// private key so the flow setup fails immediately instead of dialing, which is
// enough to show the branch was entered — the refusal itself is never what comes
// back out.
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
breakProfilePrivateKey(t, cfgPath)
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
return internal.StatusNeedsLogin, refused
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.NotErrorIs(t, err, refused,
"the refusal was handed back to the caller instead of starting the SSO flow")
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, stateErr)
require.Equal(t, internal.StatusLoginFailed, status,
"the SSO flow setup was never reached with the broken key")
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
t.Helper()
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
cfg["PrivateKey"] = "not-a-key"
patched, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -50,9 +51,6 @@ const (
defaultMaxRetryTime = 14 * 24 * time.Hour
defaultRetryMultiplier = 1.7
// JWT token cache TTL for the client daemon (disabled by default)
defaultJWTCacheTTL = 0
errRestoreResidualState = "failed to restore residual state: %v"
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
@@ -134,12 +132,7 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// loginAttemptFn stands in for the Management login round trip. Tests set
// it to drive the login outcomes that need a server on the other end;
// production leaves it nil, and every login goes through loginAttempt.
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
jwtCache *jwtcache.Cache
}
type oauthAuthFlow struct {
@@ -161,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
updateSettingsDisabled: updateSettingsDisabled,
captureEnabled: captureEnabled,
networksDisabled: networksDisabled,
jwtCache: newJWTCache(),
jwtCache: jwtcache.New(),
extendAuthSessionFlow: auth.NewPendingFlow(),
probeThrottle: newProbeThrottle(probeThreshold),
}
@@ -375,19 +368,7 @@ func (s *Server) connectionGoroutineRunning() bool {
}
}
// attemptLogin runs a login round trip against Management, or the stand-in a
// test installed in place of it.
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
if s.loginAttemptFn != nil {
return s.loginAttemptFn(ctx, setupKey, jwtToken)
}
return s.loginAttempt(ctx, setupKey, jwtToken)
}
// loginAttempt attempts to login using the provided information. It returns
// StatusNeedsLogin when Management refused the peer's credentials and
// StatusLoginFailed for every other failure, so callers can tell an
// authentication decision apart from a login that never got made.
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
@@ -640,23 +621,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
loginStatus, err := s.attemptLogin(ctx, "", "")
if err == nil {
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
// Only an authentication refusal means the peer has to (re-)authenticate.
// Any other failure leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
// something that is not theirs to solve, and a browser login cannot succeed
// while Management is unreachable anyway.
if loginStatus != internal.StatusNeedsLogin {
state.Set(loginStatus)
return nil, err
}
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
@@ -713,7 +682,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
// which returns NeedsLogin and parks on the browser leg.
state.Set(internal.StatusConnecting)
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -868,7 +837,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -1653,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration {
config := s.config
s.mutex.Unlock()
if config == nil || config.SSHJWTCacheTTL == nil {
return defaultJWTCacheTTL
if config == nil {
return jwtcache.DefaultTTL
}
seconds := *config.SSHJWTCacheTTL
if seconds == 0 {
log.Debug("SSH JWT cache disabled (configured to 0)")
return 0
}
ttl := time.Duration(seconds) * time.Second
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
return ttl
return jwtcache.ResolveTTL(config.SSHJWTCacheTTL)
}
// RequestJWTAuth initiates JWT authentication flow for SSH
@@ -1687,7 +1648,7 @@ func (s *Server) RequestJWTAuth(
jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 {
if cachedToken, found := s.jwtCache.get(); found {
if cachedToken, found := s.jwtCache.Get(); found {
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
return &proto.RequestJWTAuthResponse{
@@ -1760,7 +1721,7 @@ func (s *Server) WaitJWTToken(
jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 {
s.jwtCache.store(token, jwtCacheTTL)
s.jwtCache.Store(token, jwtCacheTTL)
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
} else {
log.Debug("JWT caching disabled, not storing token")

View File

@@ -0,0 +1,109 @@
// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens.
// The token is kept in a secure memguard enclave and wiped from memory when it
// expires. It is shared by the daemon gRPC server and the mobile SDKs, which
// have no daemon process to delegate caching to.
package jwtcache
import (
"sync"
"time"
"github.com/awnumar/memguard"
log "github.com/sirupsen/logrus"
)
// DefaultTTL is used when no TTL is configured: caching disabled.
const DefaultTTL = 0
// Cache stores a single JWT token in a secure enclave until it expires.
type Cache struct {
mu sync.RWMutex
enclave *memguard.Enclave
expiresAt time.Time
timer *time.Timer
maxTokenSize int
}
// New creates an empty Cache.
func New() *Cache {
return &Cache{
maxTokenSize: 8192,
}
}
// Store caches the token for maxAge. A previously stored token is wiped.
func (c *Cache) Store(token string, maxAge time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.cleanup()
if c.timer != nil {
c.timer.Stop()
}
tokenBytes := []byte(token)
c.enclave = memguard.NewEnclave(tokenBytes)
c.expiresAt = time.Now().Add(maxAge)
var timer *time.Timer
timer = time.AfterFunc(maxAge, func() {
c.mu.Lock()
defer c.mu.Unlock()
if c.timer != timer {
return
}
c.cleanup()
c.timer = nil
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
})
c.timer = timer
}
// Get returns the cached token, or false if none is stored or it has expired.
func (c *Cache) Get() (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.enclave == nil || time.Now().After(c.expiresAt) {
return "", false
}
buffer, err := c.enclave.Open()
if err != nil {
log.Debugf("Failed to open JWT token enclave: %v", err)
return "", false
}
defer buffer.Destroy()
token := string(buffer.Bytes())
return token, true
}
// cleanup destroys the secure enclave, must be called with lock held
func (c *Cache) cleanup() {
if c.enclave != nil {
c.enclave = nil
}
c.expiresAt = time.Time{}
}
// ResolveTTL converts the configured TTL (seconds, from
// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL
// when unset; 0 means caching is disabled.
func ResolveTTL(configuredSeconds *int) time.Duration {
if configuredSeconds == nil {
return DefaultTTL
}
seconds := *configuredSeconds
if seconds == 0 {
log.Debug("SSH JWT cache disabled (configured to 0)")
return 0
}
ttl := time.Duration(seconds) * time.Second
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
return ttl
}

View File

@@ -26,17 +26,17 @@ contents:
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1 (>= 4.14)
- libgtk-4-1
- libwebkitgtk-6.0-4
- xdg-utils
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- gtk4
- webkitgtk6.0
- xdg-utils
# Arch Linux packages

View File

@@ -43,12 +43,7 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
}
async function runSsoLogin(
result: {
verificationUri: string;
verificationUriComplete: string;
userCode: string;
profileId: string;
},
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
state: SsoState,
signal?: AbortSignal,
): Promise<void> {
@@ -61,7 +56,7 @@ async function runSsoLogin(
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
{ userCode: result.userCode, hostname: "" },
{ profileName: "", username: "" },
);

View File

@@ -14,6 +14,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
@@ -62,7 +63,7 @@ type registeredServices struct {
profiles *services.Profiles
update *services.Update
daemonFeed *services.DaemonFeed
notifier *Notifier
notifier *notifications.NotificationService
compat *services.Compat
profileSwitcher *services.ProfileSwitcher
bundle *i18n.Bundle
@@ -101,7 +102,7 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := newNotifier()
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
@@ -209,7 +210,7 @@ func main() {
// requestNotificationAuthorization prompts for macOS notification permission.
// The request blocks until the user responds (up to 3 minutes), so callers run
// it in a goroutine. No-op on Linux/Windows.
func requestNotificationAuthorization(notifier *Notifier) {
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
authorized, err := notifier.CheckNotificationAuthorization()
if err != nil {
logrus.Debugf("check notification authorization: %v", err)

View File

@@ -1,101 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"errors"
"sync/atomic"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
var errNotificationsUnavailable = errors.New("notifications unavailable")
// Notifier wraps the Wails notification service so an unavailable backend
// disables notifications instead of aborting the app. Startup fails for
// environment reasons (a bare unbundled binary on macOS has no bundle
// identifier, a headless Linux session has no D-Bus session bus), and Wails
// treats a service startup error as fatal. After a failed startup every call
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
// identifier raises an Objective-C exception that recover() cannot catch.
type Notifier struct {
inner *notifications.NotificationService
available atomic.Bool
}
func newNotifier() *Notifier {
return &Notifier{inner: notifications.New()}
}
// ServiceName implements the Wails service-name hook for startup logs.
func (n *Notifier) ServiceName() string {
return n.inner.ServiceName()
}
// ServiceStartup starts the platform notifier, downgrading failure to a
// warning so the app keeps running without notifications.
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := n.inner.ServiceStartup(ctx, options); err != nil {
log.Warnf("notifications disabled: %v", err)
return nil
}
n.available.Store(true)
return nil
}
func (n *Notifier) ServiceShutdown() error {
if !n.available.Load() {
return nil
}
return n.inner.ServiceShutdown()
}
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.CheckNotificationAuthorization()
}
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.RequestNotificationAuthorization()
}
// SendNotification delivers a notification, silently dropping it when the
// backend never started (notifications are best-effort everywhere).
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotification(options)
}
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotificationWithActions(options)
}
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
if !n.available.Load() {
return nil
}
return n.inner.RegisterNotificationCategory(category)
}
// OnNotificationResponse registers the response callback. Pure Go state, so
// it is safe (and simply inert) when the backend never started.
//
//wails:ignore
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
n.inner.OnNotificationResponse(callback)
}

View File

@@ -246,7 +246,6 @@ func (s *Store) ExistedAtLoad() bool {
func (s *Store) load() error {
if _, err := os.Stat(s.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Infof("no ui preferences file at %s; using defaults", s.path)
return nil
}
return fmt.Errorf("stat preferences: %w", err)

View File

@@ -33,21 +33,12 @@ type LoginResult struct {
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
// ProfileID is the ID of the profile this login ran against, or "" when the
// caller named the profile itself and no ID was resolved. Pass it back in
// WaitSSOParams so the account email lands on this profile even if the
// active one changes during SSO.
ProfileID string `json:"profileId"`
}
// WaitSSOParams are the inputs to waitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
// ProfileID is the profile the login was started for, used to file the
// account email against it rather than against whichever profile is active
// when the flow returns. Optional: empty falls back to the active profile.
ProfileID string `json:"profileId"`
}
// UpParams selects the profile to bring up.
@@ -86,16 +77,11 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
// Fall back to the daemon's active profile and the current OS user.
profileName := p.ProfileName
username := p.Username
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
// a handle — a display name or an ID prefix resolve too — and the state file
// is named after the ID, so passing a handle on would name the wrong file.
profileID := ""
if profileName == "" {
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
// Address the active profile by ID (the daemon resolves it as a
// handle); names can collide, the ID cannot.
profileName = active.GetId()
profileID = profileName
if username == "" {
username = active.GetUsername()
}
@@ -136,7 +122,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
UserCode: resp.GetUserCode(),
VerificationURI: resp.GetVerificationURI(),
VerificationURIComplete: resp.GetVerificationURIComplete(),
ProfileID: profileID,
}, nil
}
@@ -257,31 +242,6 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
return "", s.classifyDaemonError(err)
}
log.Infof("SSO login completed, daemon reported success")
// Persist the account email the same way the CLI does after its own
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
// root and the per-profile state file is user-owned (see Logout below).
// Without this the profile has no email, so Profiles.List shows no account
// and later logins and session extends go out without a login_hint —
// leaving the IdP to guess which account was meant.
if email := resp.GetEmail(); email != "" {
state := &profilemanager.ProfileState{Email: email}
pm := profilemanager.NewProfileManager()
// Against the profile the login was started for: SSO spans seconds of
// user interaction, and a profile switch in that window would otherwise
// file the email under the wrong profile.
if p.ProfileID != "" {
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
} else {
err = pm.SetActiveProfileState(state)
}
if err != nil {
// Non-fatal: the login itself succeeded.
log.Warnf("failed to store account email: %v", err)
}
}
return resp.GetEmail(), nil
}

View File

@@ -6,8 +6,6 @@ import (
"context"
"os/user"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
@@ -153,31 +151,11 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
if err != nil {
return err
}
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return err
}
// The daemon deletes what it owns but runs as root, so it leaves the
// user-owned state file holding the account email behind (same split as
// Connection.Logout). Legacy profiles are keyed by name rather than by a
// generated ID, so a recreated profile of the same name would inherit the
// deleted one's email and offer it as the login_hint.
//
// Keyed on the ID the daemon resolved, not on the request handle: that may
// have been a display name or an ID prefix, which would name a different
// file (or none).
if id := resp.GetId(); id != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
// Non-fatal: the profile itself is gone.
log.Warnf("failed to remove profile state for %s: %v", id, err)
}
}
return nil
return err
}
// Rename changes a profile's display name. The on-disk ID is unaffected, so

View File

@@ -44,7 +44,7 @@ type TrayServices struct {
Profiles *services.Profiles
Networks *services.Networks
DaemonFeed *services.DaemonFeed
Notifier *Notifier
Notifier *notifications.NotificationService
Update *services.Update
ProfileSwitcher *services.ProfileSwitcher
WindowManager *services.WindowManager

View File

@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
ready, err := compat.DaemonReady(context.Background())
if err != nil {
log.Debugf("daemon compatibility probe: %v", err)

View File

@@ -21,7 +21,7 @@ type trayUpdater struct {
app *application.App
window *application.WebviewWindow
update *services.Update
notifier *Notifier
notifier *notifications.NotificationService
loc *Localizer
onIconChange func()
// onMenuChange drives a full tray relayout: the update row lives in the
@@ -36,7 +36,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,

View File

@@ -160,19 +160,8 @@ func TestSettingsRoundTrip(t *testing.T) {
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
// A cluster different from the pinned one must be rejected; echoing the
// pinned one back is valid.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("attacker.cluster.invalid"),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
})
requireClientError(t, err)
// Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(before.Cluster),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,

View File

@@ -1,114 +0,0 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper()
fresh, err := harness.StartCombined(ctx)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
if _, err := fresh.Bootstrap(ctx); err != nil {
return nil, err
}
return fresh, nil
}
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// account that has never been bootstrapped: the GET reads as the defaults
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
// pins it immutably. The shared srv cannot provide that starting state (any
// provider-creating test bootstraps it, and test order is deliberately not
// relied on), so this boots a dedicated combined server — the image is
// already built and cached by TestMain's StartCombined, so the extra cost is
// one container start.
func TestSettingsBootstrapViaPut(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
// Before agent-network bootstrap the settings read as the defaults, not
// as an error and not as a null body.
before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT without a cluster has nothing to pin the account to.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
})
requireClientError(t, err)
// A PUT carrying a cluster bootstraps the account and applies the
// mutable fields from the same request. Every toggle is set away from
// its bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: false,
EnablePromptCollection: true,
RedactPii: true,
})
require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
// The row is persisted: an independent read agrees on every field.
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after bootstrap must succeed")
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, later updates may omit the cluster entirely.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
})
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The cluster is immutable: a different value is rejected rather than
// silently ignored, and the rejected update must not disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: false,
})
requireClientError(t, err)
final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
}

2
go.mod
View File

@@ -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-beta.3
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
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

4
go.sum
View File

@@ -660,8 +660,8 @@ 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-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA=
github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
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=

View File

@@ -17,7 +17,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
@@ -62,23 +61,10 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
// Swallow activity events so the mutation paths (create/update/delete)
// are exercisable through the HTTP layer.
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
accounts.EXPECT().
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, accounts, nil)
manager := agentnetwork.NewManager(st, perms, nil, nil)
h := &handler{manager: manager}
router := mux.NewRouter()
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
h.addPolicyEndpoints(router)
h.addConsumptionEndpoints(router)
h.addBudgetRuleEndpoints(router)

View File

@@ -1,9 +1,7 @@
package handlers
import (
"encoding/json"
"math"
nethttp "net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -53,50 +51,3 @@ func TestValidate_ModelRates(t *testing.T) {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
// with the other PUT endpoints: the request replaces the provider's mutable
// state, so optional fields absent from the JSON land as their zero values.
// The two exceptions are server-side: the api_key (a secret — omitted means
// "not rotated") and the session keypair, both preserved by the manager. The
// identity headers stay on the wire as explicit empty strings so a cleared
// value round-trips.
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
create := `{
"provider_id": "openai_api",
"name": "openai",
"upstream_url": "https://api.openai.com",
"api_key": "sk-test",
"enabled": true,
"metadata_disabled": true,
"skip_tls_verification": true,
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
"identity_header_user_id": "x-bf-dim-netbird_user_id",
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
}`
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
var created api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
// Minimal update: only the required fields, no api_key. Everything
// optional must land as its zero value.
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
var updated api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
assert.True(t, updated.Enabled, "sent field must apply")
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
assert.Empty(t, updated.Models, "omitted models must be cleared")
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
"cleared identity header must round-trip as an explicit empty string")
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/gorilla/mux"
@@ -10,20 +11,19 @@ import (
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// addSettingsEndpoints registers the Agent Network settings routes. The
// settings row is bootstrapped server-side on first provider create or on the
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
// of the mutable collection toggles (cluster/subdomain stay immutable).
// settings row is bootstrapped server-side on first provider create; GET reads
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
// immutable).
func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
}
// updateSettings replaces the mutable settings fields on the account's row.
// A request carrying a cluster bootstraps the row when the account doesn't
// have one yet.
// updateSettings applies the collection toggles to the account's settings row.
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -48,9 +48,11 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
}
// getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view.
// getSettings returns the account's agent-network settings. The settings
// row is bootstrapped on first provider create, so freshly-onboarded
// accounts have nothing to read. Rather than 404-ing in that case (which
// the dashboard would have to special-case), return a JSON null with 200
// so consumers can branch on the body alone.
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -60,6 +62,11 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
util.WriteJSONObject(r.Context(), w, nil)
return
}
util.WriteError(r.Context(), err, w)
return
}

View File

@@ -1,137 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
// convention shared with the account and DNS settings endpoints: settings
// always read as a JSON object. Before bootstrap that object carries the
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
// signal) and no timestamps — never a 404 and never the legacy null body.
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code,
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
"the legacy 200+null shape must not come back")
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
assert.False(t, got.RedactPii, "defaults must show redaction off")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
}
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
// creates the row (cluster pinned, subdomain assigned) and applies the
// mutable fields from the same request.
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
// The row is now readable via GET.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
}
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
// without a cluster cannot conjure a settings row out of nothing — there is
// no cluster to pin — and surfaces as 404 like the GET.
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusNotFound, rec.Code,
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "cluster",
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
}
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
// with the other PUT endpoints: the request replaces every mutable field, so a
// toggle absent from the JSON lands as its zero value rather than being
// preserved. Cluster and subdomain survive untouched.
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
assert.False(t, got.RedactPii, "sent toggle must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays,
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
}
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
// assigned, a differing cluster is rejected as a validation error instead of
// being silently ignored, so callers never observe a value other than the one
// they sent. Echoing the assigned cluster back stays valid, which lets
// declarative clients send their full desired state idempotently.
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
}

View File

@@ -82,11 +82,6 @@ type Manager interface {
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
RecordUsage(ctx context.Context, in RecordUsageInput) error
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
// GetSetupForPeer returns the Agent Network connection info the peer's
// groups authorize (endpoint, providers, effective models). Caller-scoped
// by design — no user permission gate; see the implementation.
GetSetupForPeer(ctx context.Context, accountID, peerID string) (*types.EffectiveSetup, error)
}
// PolicySelectionInput is the per-request selection envelope. The
@@ -162,14 +157,14 @@ func NewManager(
}
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
@@ -180,14 +175,9 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
// been created yet; otherwise it is ignored (the cluster is pinned on
// Settings and every provider in the account routes through it).
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
return nil, err
}
if strings.TrimSpace(bootstrapCluster) != "" {
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
return nil, err
}
}
// An empty api_key would silently produce a synthesised service
// that 401s on every upstream request. Surface the misconfiguration
@@ -212,7 +202,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
if strings.TrimSpace(bootstrapCluster) != "" {
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
// The provider create has already succeeded; logging the
// bootstrap miss matches the plan's PoC behaviour. The synth
// path treats a missing settings row as a no-op, and the next
@@ -228,7 +218,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -267,7 +257,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -316,21 +306,21 @@ func pluralize(n int, singular, plural string) string {
}
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
}
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -356,7 +346,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -383,7 +373,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -403,21 +393,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
}
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
}
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -439,7 +429,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -462,7 +452,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -483,7 +473,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
// GetAllBudgetRules returns every account-level budget rule for the account.
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
@@ -491,7 +481,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
// GetBudgetRule returns a single account-level budget rule.
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
@@ -501,7 +491,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
// proxy config, so no reconcile is needed.
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -523,7 +513,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
// UpdateBudgetRule updates an existing account-level budget rule.
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -546,7 +536,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
// DeleteBudgetRule removes an account-level budget rule.
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -564,83 +554,40 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
return nil
}
// UpdateSettings replaces the mutable account-level settings — the collection
// toggles and retention — on the account's row. When the account has no
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
// first provider create); without it the update fails with NotFound. On an
// existing row the cluster and subdomain are immutable: a differing
// settings.Cluster is rejected rather than silently ignored so callers never
// observe a value other than what they sent. Because the collection toggles
// change the synthesised service config (prompt-capture gating, access-log
// emission), a reconcile is triggered so the proxy and peer network maps
// converge on the new state.
// UpdateSettings applies the mutable account-level settings — the collection
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
// preserved from the persisted row regardless of the input. Because the
// collection toggles change the synthesised service config (prompt-capture
// gating, access-log emission), a reconcile is triggered so the proxy and peer
// network maps converge on the new state.
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
return nil, err
}
requestedCluster := strings.TrimSpace(settings.Cluster)
// The row lock from LockingStrengthUpdate only holds for the duration of
// the surrounding transaction, so the read, the cluster-immutability
// check, and the save must share one — otherwise concurrent PUTs could
// interleave between them.
var updated *types.Settings
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
switch {
case err == nil:
if requestedCluster != "" && requestedCluster != existing.Cluster {
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
}
case isNotFound(err):
if requestedCluster == "" {
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
}
// Bootstrapping pins the cluster and subdomain — a settings
// create on top of the update the caller already passed, matching
// the gate on the provider-create bootstrap path.
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
return err
}
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
if err != nil {
return err
}
default:
return fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
return fmt.Errorf("save agent network settings: %w", err)
}
updated = existing
return nil
})
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
if err != nil {
return nil, err
return nil, fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
}
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
"log_collection": updated.EnableLogCollection,
"prompt_collection": updated.EnablePromptCollection,
"redact_pii": updated.RedactPii,
"log_collection": existing.EnableLogCollection,
"prompt_collection": existing.EnablePromptCollection,
"redact_pii": existing.RedactPii,
})
m.reconcile(ctx, settings.AccountID)
return updated, nil
}
// isNotFound reports whether err is a status.NotFound error.
func isNotFound(err error) bool {
var sErr *status.Error
return errors.As(err, &sErr) && sErr.Type() == status.NotFound
return existing, nil
}
// validateProviderRefs ensures every destination provider id refers to a
@@ -664,38 +611,14 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
return nil
}
// GetSettings returns the agent-network settings row for the account. When no
// row has been bootstrapped yet, the defaults are returned (without
// persisting) with cluster and subdomain empty — settings always read as an
// object, like the account and DNS settings endpoints.
// GetSettings returns the agent-network settings row for the account.
// Returns the underlying status.NotFound when no row has been
// bootstrapped yet (i.e. the account has no providers).
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
return settings, nil
case isNotFound(err):
return types.DefaultSettings(accountID), nil
default:
return nil, err
}
}
// 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
// provider one. No-op once the settings row exists.
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return nil
}
if !isNotFound(err) {
return fmt.Errorf("get agent network settings: %w", err)
}
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
@@ -703,9 +626,8 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
// 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. st is the store to
// operate on — pass the transaction store when calling from within one.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
// it is returned untouched and the 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")
}
@@ -713,15 +635,16 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
}
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return existing, nil
}
if !isNotFound(err) {
var sErr *status.Error
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
return nil, fmt.Errorf("get agent network settings: %w", err)
}
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
@@ -740,12 +663,18 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
m.labelRngMu.Unlock()
now := time.Now().UTC()
settings := types.DefaultSettings(accountID)
settings.Cluster = providerCluster
settings.Subdomain = subdomain
settings.CreatedAt = now
settings.UpdatedAt = now
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
settings := &types.Settings{
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,
}
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
}
return settings, nil
@@ -756,7 +685,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
// counter view; permission gate is the same Read role that gates
// every other agent-network surface.
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
@@ -765,7 +694,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -775,7 +704,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
@@ -784,7 +713,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -858,8 +787,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
}
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -948,8 +877,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
return types.DefaultSettings(accountID), nil
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
return nil, status.Errorf(status.NotFound, "agent network settings not found")
}
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {

View File

@@ -1,134 +0,0 @@
package agentnetwork
import (
"context"
"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/types"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
// so tests can grant the provider permission while denying (or never
// expecting) the settings one.
type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
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)
ctrl := gomock.NewController(t)
perms := permissions.NewMockManager(ctrl)
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
store: st,
perms: perms,
}
}
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
f.perms.EXPECT().
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
Return(allowed, context.Background(), nil)
}
func newBootstrapProvider(accountID string) *types.Provider {
p := types.NewProvider(accountID)
p.Name = "openai"
p.UpstreamURL = "https://api.openai.com"
p.APIKey = "sk-test"
p.Enabled = true
return p
}
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
// one-time settings bootstrap: creating the first provider with a
// bootstrap_cluster pins the account's cluster and subdomain, which is a
// settings write and must not ride on the providers permission alone.
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
ctx := context.Background()
t.Run("denied without settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.Error(t, err, "bootstrap without settings permission must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
})
t.Run("allowed with settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "bootstrap with both permissions must succeed")
require.NotNil(t, created)
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err, "bootstrap must create the settings row")
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
})
t.Run("existing settings need no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "existing",
}), "pre-existing settings row setup must succeed")
// Only the providers permission may be consulted: gomock fails the
// test on any unexpected settings-permission call.
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "create with existing settings must not require the settings permission")
})
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
require.NoError(t, err, "create without bootstrap must not require the settings permission")
})
}

View File

@@ -1,240 +0,0 @@
package agentnetwork
import (
"context"
"fmt"
"sort"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
)
// GetSetupForPeer returns the Agent Network setup the calling peer's
// groups authorize. It deliberately performs no user-permission check:
// peer RPCs carry no user identity — WireGuard key possession is the
// credential (same trust model as SelectPolicyForRequest) — and the
// result is caller-scoped, which is strictly tighter than any role gate.
// Setup-key/machine peers (no user attached) are fully supported since
// the computation runs on peer groups, which is what the proxy enforces.
func (m *managerImpl) GetSetupForPeer(ctx context.Context, accountID, peerID string) (*types.EffectiveSetup, error) {
groupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return nil, fmt.Errorf("get peer groups: %w", err)
}
return m.effectiveSetupForGroups(ctx, accountID, groupIDs)
}
// effectiveSetupForGroups computes the effective Agent Network setup for
// a set of caller groups: the account endpoint plus, per authorized
// provider, the effective model set. It mirrors what the proxy enforces
// at request time — the policy filter matches filterApplicablePolicies,
// the model logic matches policyPermitsModel, and orphan providers
// (enabled but referenced by no applicable policy) are omitted just like
// the router synthesizer omits them — so the answer never advertises
// anything the proxy would refuse.
//
// Every "nothing available" shape returns Configured=false rather than
// an error, and "account not set up" is indistinguishable from "caller
// has no access" by design: the response must not leak what exists for
// others.
func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) {
notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
case isNotFound(err):
return notConfigured, nil
default:
return nil, fmt.Errorf("get agent network settings: %w", err)
}
if settings.Endpoint() == "" {
return notConfigured, nil
}
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account policies: %w", err)
}
applicable := filterPoliciesByGroups(policies, groupIDs)
if len(applicable) == 0 {
return notConfigured, nil
}
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account providers: %w", err)
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
authorized := make([]*types.Provider, 0, len(providers))
for _, p := range providers {
if p == nil || !p.Enabled {
continue
}
if len(policiesForProvider(applicable, p.ID)) == 0 {
continue
}
authorized = append(authorized, p)
}
if len(authorized) == 0 {
return notConfigured, nil
}
// created_at order, ID tiebreak — same deterministic order the router
// synthesizer presents.
sort.SliceStable(authorized, func(i, j int) bool {
if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) {
return authorized[i].CreatedAt.Before(authorized[j].CreatedAt)
}
return authorized[i].ID < authorized[j].ID
})
out := &types.EffectiveSetup{
Configured: true,
Endpoint: "https://" + settings.Endpoint(),
Providers: make([]types.EffectiveProvider, 0, len(authorized)),
}
for _, p := range authorized {
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
flavor := ""
if entry, ok := catalog.Lookup(p.ProviderID); ok {
flavor = entry.ParserID
}
out.Providers = append(out.Providers, types.EffectiveProvider{
Name: p.Name,
CatalogID: p.ProviderID,
APIFlavor: flavor,
AllModelsAllowed: allAllowed,
Models: models,
})
}
return out, nil
}
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
// intersect the caller's groups. Same group matching as
// filterApplicablePolicies, without the per-provider filter — the setup
// answer spans every provider the caller can reach.
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
groupSet := make(map[string]struct{}, len(groupIDs))
for _, g := range groupIDs {
if g != "" {
groupSet[g] = struct{}{}
}
}
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if p == nil || !p.Enabled {
continue
}
if !anyGroupMatches(p.SourceGroups, groupSet) {
continue
}
out = append(out, p)
}
return out
}
// policiesForProvider returns the subset of policies targeting the
// provider, order preserved.
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if sliceContains(p.DestinationProviderIDs, providerID) {
out = append(out, p)
}
}
return out
}
// effectiveModelsForProvider derives the caller's effective model set for
// one provider from the applicable policies that target it, mirroring
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
// unrestricted, and one unrestricted policy makes the whole provider
// unrestricted (the proxy would admit any model through it). Otherwise
// the union of the policies' allowlists applies, intersected with the
// provider's declared models when the operator declared any — the router
// only claims declared models, so an allowlisted-but-undeclared model is
// unreachable and must not be advertised. With no declared models the
// router claims every model, so the allowlist union stands alone.
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
restricted := true
union := make([]string, 0)
seen := make(map[string]struct{})
for _, p := range policies {
policyRestricted := false
for _, gID := range p.GuardrailIDs {
g, ok := guardrailsByID[gID]
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
continue
}
policyRestricted = true
for _, model := range g.Checks.ModelAllowlist.Models {
key := normaliseModelID(model)
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
union = append(union, key)
}
}
if !policyRestricted {
restricted = false
}
}
declared := declaredModelIDs(provider)
if !restricted {
return true, declared
}
if len(provider.Models) == 0 {
// No operator declaration: the router claims every model, so the
// allowlist union is the effective set as-is.
return false, union
}
out := make([]string, 0, len(declared))
for _, id := range declared {
if _, ok := seen[normaliseModelID(id)]; ok {
out = append(out, id)
}
}
return false, out
}
// declaredModelIDs returns the models a provider exposes: the operator's
// curated list when present, otherwise the catalog entry's models (an
// empty operator list means "all catalog models"). Gateway/custom catalog
// entries declare no models, so the result may be empty.
func declaredModelIDs(provider *types.Provider) []string {
if ids := providerModelIDs(provider); len(ids) > 0 {
return ids
}
entry, ok := catalog.Lookup(provider.ProviderID)
if !ok {
return []string{}
}
out := make([]string, 0, len(entry.Models))
for _, m := range entry.Models {
if m.ID != "" {
out = append(out, m.ID)
}
}
return out
}
// GetSetupForPeer on the mock manager reports "not configured" so tests
// that don't care about setup still compile.
func (*mockManager) GetSetupForPeer(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
}

View File

@@ -1,271 +0,0 @@
package agentnetwork
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
)
// These tests drive the effective-setup computation through the real
// sqlite store, mirroring the policyselect realstore suite: assert on
// observable answers (configured / providers / models), not on which
// store methods get called. The computation must agree with what the
// proxy enforces — policy filtering matches filterApplicablePolicies,
// model logic matches policyPermitsModel, and orphan providers are
// omitted like the router synthesizer omits them.
func newSetupTestMgr(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
t.Cleanup(cleanup)
return &managerImpl{store: s}, s
}
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
return &types.Guardrail{
ID: id,
AccountID: testAccountID,
Name: "allowlist " + id,
Checks: types.GuardrailChecks{
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
},
}
}
func TestEffectiveSetup_RealStore_NoSettingsRow(t *testing.T) {
mgr, _ := newSetupTestMgr(t)
setup, err := mgr.effectiveSetupForGroups(context.Background(), testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "account without settings must read as not configured")
assert.Empty(t, setup.Endpoint)
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-other"})
require.NoError(t, err)
assert.False(t, setup.Configured, "caller outside every policy's source groups must read as not configured")
assert.Empty(t, setup.Endpoint, "no-access answer must not leak the endpoint")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.Equal(t, "OpenAI", p.Name)
assert.Equal(t, "openai_api", p.CatalogID)
assert.Equal(t, "openai", p.APIFlavor)
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
}
func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
// (NOT declared — the router would never route it, so it must not be
// advertised).
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
open.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
assert.True(t, setup.Providers[0].AllModelsAllowed,
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
}
func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
p2.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
}
func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Orphan: enabled but referenced by no policy.
orphan := newSynthTestProvider()
orphan.ID = "prov-orphan"
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
// Disabled but referenced by an applicable policy.
disabled := newSynthTestProvider()
disabled.ID = "prov-disabled"
disabled.Enabled = false
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "neither an orphan nor a disabled provider is reachable, so nothing is configured for the caller")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
policy.Enabled = false
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured)
}
func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Gateway-style provider: no declared models — the router claims every
// model, so the allowlist union is the effective set on its own.
provider := newSynthTestProvider()
provider.ProviderID = "litellm_proxy"
provider.Name = "LiteLLM"
provider.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
}
func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
newer := newSynthTestProvider()
newer.ID = "prov-newer"
newer.Name = "Newer"
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
older := newSynthTestProvider()
older.ID = "prov-older"
older.Name = "Older"
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 2)
assert.Equal(t, "Older", setup.Providers[0].Name)
assert.Equal(t, "Newer", setup.Providers[1].Name)
}
// TestGetSetupForPeer_RealStore pins the peer entry point: the peer's
// group memberships (not any user identity) scope the answer, which is
// exactly what the proxy enforces via AllowedGroupIDs — including for
// setup-key/machine peers that have no user attached.
func TestGetSetupForPeer_RealStore(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
require.NoError(t, s.AddPeerToGroup(ctx, testAccountID, "peer-in", "grp-eng"))
require.NoError(t, s.AddPeerToGroup(ctx, testAccountID, "peer-out", "grp-other"))
setupIn, err := mgr.GetSetupForPeer(ctx, testAccountID, "peer-in")
require.NoError(t, err)
assert.True(t, setupIn.Configured)
require.Len(t, setupIn.Providers, 1)
setupOut, err := mgr.GetSetupForPeer(ctx, testAccountID, "peer-out")
require.NoError(t, err)
assert.False(t, setupOut.Configured, "peer outside the policy's source groups gets the not-configured answer")
}

View File

@@ -164,7 +164,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
p.MetadataDisabled = *req.MetadataDisabled
}
// Identity-header overrides for catalogs flagged Customizable.
// Empty or omitted disables stamping for this dimension.
// nil pointer = "field omitted on the wire" → leave the stored
// value untouched (per the openapi description). Empty string is
// an explicit clear that disables stamping for this dimension.
if req.IdentityHeaderUserId != nil {
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
}
@@ -190,20 +192,16 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
created := p.CreatedAt
updated := p.UpdatedAt
resp := &api.AgentNetworkProvider{
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
// Always present on the wire so an explicitly cleared header
// round-trips as "" instead of vanishing from the response.
IdentityHeaderUserId: p.IdentityHeaderUserID,
IdentityHeaderGroups: p.IdentityHeaderGroups,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
}
if len(p.ExtraValues) > 0 {
out := make(map[string]string, len(p.ExtraValues))
@@ -212,6 +210,14 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
}
resp.ExtraValues = &out
}
if p.IdentityHeaderUserID != "" {
v := p.IdentityHeaderUserID
resp.IdentityHeaderUserId = &v
}
if p.IdentityHeaderGroups != "" {
v := p.IdentityHeaderGroups
resp.IdentityHeaderGroups = &v
}
return resp
}

View File

@@ -77,41 +77,3 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
}
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
// fields are always present in the API response — an explicitly cleared
// ("") header must round-trip as "" rather than vanish, so API consumers
// (e.g. the Terraform provider) never observe a value other than the one
// they wrote.
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
set := "x-bf-dim-netbird_user_id"
empty := ""
base := func() *api.AgentNetworkProviderRequest {
return &api.AgentNetworkProviderRequest{
ProviderId: "custom",
Name: "bifrost",
UpstreamUrl: "https://bifrost.internal",
}
}
p := NewProvider("acc-1")
resp := p.ToAPIResponse()
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
req := base()
req.IdentityHeaderUserId = &set
p.FromAPIRequest(req)
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
// Omitting the field preserves it.
p.FromAPIRequest(base())
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
// An explicit "" clears it AND stays visible on the wire.
req = base()
req.IdentityHeaderUserId = &empty
p.FromAPIRequest(req)
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
}

View File

@@ -1,7 +1,6 @@
package types
import (
"strings"
"time"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -43,34 +42,18 @@ type Settings struct {
// schema cohesive.
func (Settings) TableName() string { return "agent_network_settings" }
// DefaultSettings returns the settings an account observes before its row is
// bootstrapped: log collection on with the default retention, everything else
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
// and the freshly bootstrapped row agree.
func DefaultSettings(accountID string) *Settings {
return &Settings{
AccountID: accountID,
EnableLogCollection: true,
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
}
}
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
// `<subdomain>.<cluster>`.
func (s *Settings) Endpoint() string {
if s.Cluster == "" || s.Subdomain == "" {
return ""
}
return s.Subdomain + "." + s.Cluster
}
// ToAPIResponse renders the settings as the API representation. The
// timestamps are omitted while zero — a default (not yet bootstrapped) view
// has no persisted row to date.
// ToAPIResponse renders the settings as the API representation.
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
created := s.CreatedAt
updated := s.UpdatedAt
retention := s.AccessLogRetentionDays
resp := &api.AgentNetworkSettings{
return &api.AgentNetworkSettings{
Cluster: s.Cluster,
Subdomain: s.Subdomain,
Endpoint: s.Endpoint(),
@@ -78,27 +61,14 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
EnablePromptCollection: s.EnablePromptCollection,
RedactPii: s.RedactPii,
AccessLogRetentionDays: &retention,
CreatedAt: &created,
UpdatedAt: &updated,
}
if !s.CreatedAt.IsZero() {
created := s.CreatedAt
resp.CreatedAt = &created
}
if !s.UpdatedAt.IsZero() {
updated := s.UpdatedAt
resp.UpdatedAt = &updated
}
return resp
}
// FromAPIRequest applies the request onto the receiver. The mutable
// collection fields are always replaced with the request values. Cluster
// participates only in bootstrap and the immutability check (see
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
// from a request.
// FromAPIRequest applies the mutable settings fields from the request. Cluster
// and Subdomain are immutable and intentionally not touched here.
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
if req.Cluster != nil {
s.Cluster = strings.TrimSpace(*req.Cluster)
}
s.EnableLogCollection = req.EnableLogCollection
s.EnablePromptCollection = req.EnablePromptCollection
s.RedactPii = req.RedactPii

View File

@@ -1,40 +0,0 @@
package types
// EffectiveSetup is the caller-scoped answer to "what may this caller
// use on the Agent Network?" — the account's proxy endpoint plus the
// providers and models the caller's groups authorize. It intentionally
// carries display metadata only: no keys, no upstream URLs, no policy or
// guardrail structure, and no hint of providers the caller cannot reach.
type EffectiveSetup struct {
// Configured is false when the account has no Agent Network set up or
// when nothing is authorized for the caller's groups — the two cases
// are deliberately indistinguishable so the response leaks nothing
// about what exists for others.
Configured bool
// Endpoint is the account's proxy base URL
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
// only. Empty when Configured is false.
Endpoint string
// Providers lists the providers at least one applicable policy
// authorizes for the caller, in the account's created_at order.
Providers []EffectiveProvider
}
// EffectiveProvider is one authorized provider in an EffectiveSetup.
type EffectiveProvider struct {
// Name is the operator-assigned label, e.g. "Bedrock prod".
Name string
// CatalogID names the catalog entry, e.g. "anthropic_api".
CatalogID string
// APIFlavor is the request-body shape the provider speaks — the
// catalog entry's parser id ("anthropic", "openai"); empty when the
// proxy dispatches the provider by URL path instead.
APIFlavor string
// AllModelsAllowed is true when no model allowlist restricts this
// provider for the caller. Models then lists the declared/catalog
// models as a courtesy (possibly none for gateway-style providers).
AllModelsAllowed bool
// Models is the effective model allowlist for the caller, or the
// declared/catalog models when AllModelsAllowed is true.
Models []string
}

View File

@@ -24,13 +24,13 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
@@ -184,10 +184,6 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
}
// Append interceptors contributed by registered gRPC extensions. These
// run after the built-in chain (ChainUnaryInterceptor is additive).
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
if s.Config.HttpConfig.LetsEncryptDomain != "" {
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
@@ -211,19 +207,14 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
}
serviceMgr := s.ServiceManager()
srv.SetReverseProxyManager(serviceMgr)
srv.SetAgentNetworkSetupService(s.AgentNetworkManager())
if serviceMgr != nil {
serviceMgr.StartExposeReaper(context.Background())
}
mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv)
log.Info("ManagementService registered on gRPC server (agent-network setup RPC available)")
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
log.Info("ProxyService registered on gRPC server")
// Register services contributed by external modules via the extension seam.
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
return gRPCAPIHandler
})
}

View File

@@ -1,74 +0,0 @@
package server
import (
"context"
"google.golang.org/grpc"
)
// GRPCExtension bundles an external module's contribution to the management
// gRPC server: the registration of one or more services onto the shared
// grpc.Server, any server-wide interceptors those services require, and an
// optional shutdown hook. It is a generic extension point with no knowledge of
// any specific service.
type GRPCExtension struct {
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
// after the built-in services are registered. It may register any number of
// services. May be nil.
Register func(grpc.ServiceRegistrar)
// UnaryInterceptors are appended to the server's unary interceptor chain,
// running after the built-in interceptors. May be empty.
UnaryInterceptors []grpc.UnaryServerInterceptor
// StreamInterceptors are appended to the server's stream interceptor chain,
// running after the built-in interceptors. May be empty.
StreamInterceptors []grpc.StreamServerInterceptor
// Shutdown, if non-nil, is called once during Stop() with the context
// governing server shutdown, which carries a deadline. The hook MUST
// return promptly and MUST abandon its work once that context is
// cancelled or expires: it runs before the rest of Stop()'s cleanup
// (store, event store, embedded IdP) and before Stop() itself checks the
// context's deadline, so a hook that ignores the context will delay all
// of that cleanup and prevent Stop() from returning on time. May be nil.
Shutdown func(ctx context.Context)
}
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
// is first built (i.e. before Start); registrations after that have no effect.
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
s.grpcExtensions = append(s.grpcExtensions, ext)
}
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
// extension interceptors after any interceptors already present in opts.
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
for _, ext := range exts {
if len(ext.UnaryInterceptors) > 0 {
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
}
if len(ext.StreamInterceptors) > 0 {
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
}
}
return opts
}
// registerExtensions registers each extension's services onto reg.
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Register != nil {
ext.Register(reg)
}
}
}
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
// passing ctx through so hooks can honor its deadline/cancellation.
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Shutdown != nil {
ext.Shutdown(ctx)
}
}
}

View File

@@ -1,160 +0,0 @@
package server
import (
"context"
"net"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/test/bufconn"
)
// Test that an extension's interceptors and service registration are actually
// wired onto a real in-process gRPC server via the helpers, and that shutdown
// hooks run. This validates the load-bearing assumption that
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
// addition to any base chain).
func TestGRPCExtensionAppliedToServer(t *testing.T) {
var unaryCalls atomic.Int32
var streamShutdownCalled atomic.Bool
ext := GRPCExtension{
Register: func(reg grpc.ServiceRegistrar) {
healthgrpc.RegisterHealthServer(reg, health.NewServer())
},
UnaryInterceptors: []grpc.UnaryServerInterceptor{
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
unaryCalls.Add(1)
return handler(ctx, req)
},
},
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
}
exts := []GRPCExtension{ext}
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
var baseUnaryCalls atomic.Int32
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
baseUnaryCalls.Add(1)
return handler(ctx, req)
}),
}
opts = appendExtensionInterceptors(opts, exts)
srv := grpc.NewServer(opts...)
registerExtensions(srv, exts)
lis := bufconn.Listen(1024 * 1024)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
if err != nil {
t.Fatalf("health check via extension-registered service failed: %v", err)
}
if baseUnaryCalls.Load() != 1 {
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
}
if unaryCalls.Load() != 1 {
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
}
runExtensionShutdownHooks(context.Background(), exts)
if !streamShutdownCalled.Load() {
t.Error("extension shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
// a non-nil context and that it is the very same context the caller passed
// in, so hooks can rely on values/deadlines placed on it by Stop().
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
type sentinelKey struct{}
want := "shutdown-ctx-sentinel"
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx == nil {
t.Fatal("hook received a nil context")
}
got, _ := hookCtx.Value(sentinelKey{}).(string)
if got != want {
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
// context must see ctx.Err() != nil and a closed Done() channel.
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx.Err() == nil {
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
}
select {
case <-hookCtx.Done():
default:
t.Error("hook context Done() channel is not closed for a cancelled context")
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
// with a nil Shutdown hook is skipped without panicking, and that hooks for
// other extensions still run.
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
var called atomic.Bool
exts := []GRPCExtension{
{Shutdown: nil},
{Shutdown: func(context.Context) { called.Store(true) }},
}
runExtensionShutdownHooks(context.Background(), exts)
if !called.Load() {
t.Error("shutdown hook for non-nil extension was not called")
}
}
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
s := &BaseServer{}
s.RegisterGRPCExtension(GRPCExtension{})
s.RegisterGRPCExtension(GRPCExtension{})
if len(s.grpcExtensions) != 2 {
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
}
}

View File

@@ -68,11 +68,6 @@ type BaseServer struct {
proxyAuthClose func()
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
// hooks registered by external modules via RegisterGRPCExtension. Populated
// during boot (single-threaded), consumed by GRPCServer() and Stop().
grpcExtensions []GRPCExtension
listener net.Listener
certManager *autocert.Manager
update *version.Update
@@ -262,7 +257,6 @@ func (s *BaseServer) Stop() error {
s.proxyAuthClose()
s.proxyAuthClose = nil
}
runExtensionShutdownHooks(ctx, s.grpcExtensions)
_ = s.Store().Close(ctx)
_ = s.EventStore().Close(ctx)
if s.update != nil {

View File

@@ -1,81 +0,0 @@
package grpc
import (
"context"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
antypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
// AgentNetworkSetupService is the minimal slice of agentnetwork.Manager
// the peer-facing setup RPC needs. Narrow on purpose so the gRPC server
// never sees the manager's operator-facing surface.
type AgentNetworkSetupService interface {
GetSetupForPeer(ctx context.Context, accountID, peerID string) (*antypes.EffectiveSetup, error)
}
// GetAgentNetworkSetup handles a peer request for its Agent Network
// connection info. The WireGuard key is the credential (same trust model
// as the Expose RPCs); the response is scoped to what the calling peer's
// own groups authorize and carries display metadata only.
func (s *Server) GetAgentNetworkSetup(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) {
setupReq := &proto.AgentNetworkSetupRequest{}
peerKey, err := s.parseRequest(ctx, req, setupReq)
if err != nil {
return nil, err
}
accountID, peer, err := s.authenticateExposePeer(ctx, peerKey)
if err != nil {
return nil, err
}
setupSvc := s.getAgentNetworkSetupService()
if setupSvc == nil {
return nil, status.Errorf(codes.Internal, "agent network manager not available")
}
setup, err := setupSvc.GetSetupForPeer(ctx, accountID, peer.ID)
if err != nil {
log.WithContext(ctx).Errorf("get agent network setup for peer %s: %v", peer.ID, err)
return nil, status.Errorf(codes.Internal, "internal error")
}
return s.encryptResponse(peerKey, toProtoAgentNetworkSetup(setup))
}
func toProtoAgentNetworkSetup(setup *antypes.EffectiveSetup) *proto.AgentNetworkSetupResponse {
resp := &proto.AgentNetworkSetupResponse{
Configured: setup.Configured,
Endpoint: setup.Endpoint,
Providers: make([]*proto.AgentNetworkProviderInfo, 0, len(setup.Providers)),
}
for _, p := range setup.Providers {
resp.Providers = append(resp.Providers, &proto.AgentNetworkProviderInfo{
Name: p.Name,
CatalogId: p.CatalogID,
ApiFlavor: p.APIFlavor,
AllModelsAllowed: p.AllModelsAllowed,
Models: p.Models,
})
}
return resp
}
func (s *Server) getAgentNetworkSetupService() AgentNetworkSetupService {
s.agentNetworkSetupMu.RLock()
defer s.agentNetworkSetupMu.RUnlock()
return s.agentNetworkSetup
}
// SetAgentNetworkSetupService wires the agent-network setup service on
// the server.
func (s *Server) SetAgentNetworkSetupService(svc AgentNetworkSetupService) {
s.agentNetworkSetupMu.Lock()
defer s.agentNetworkSetupMu.Unlock()
s.agentNetworkSetup = svc
}

View File

@@ -61,8 +61,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
return &proto.NetworkMapEnvelope{
Payload: &proto.NetworkMapEnvelope_Full{
Full: &proto.NetworkMapComponentsFull{
Serial: networkSerial(c.Network),
Network: toAccountNetwork(c.Network),
PeerConfig: in.PeerConfig,
// components.Peers always contains the target peer
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},

View File

@@ -758,9 +758,6 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
assert.Equal(t, "netbird.cloud", full.DnsDomain)
assert.Len(t, full.Peers, 1)
assert.Empty(t, full.Policies)
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
assert.Equal(t, "net-empty", full.Network.Identifier)
assert.Equal(t, uint64(9), full.Serial)
}
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
@@ -779,12 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 9,
},
},
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
)
}

View File

@@ -87,9 +87,6 @@ type Server struct {
reverseProxyManager rpservice.Manager
reverseProxyMu sync.RWMutex
agentNetworkSetup AgentNetworkSetupService
agentNetworkSetupMu sync.RWMutex
}
// NewServer creates a new Management server

View File

@@ -102,20 +102,11 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
// A cluster different from the one pinned at bootstrap must be rejected
// outright — never silently swapped or ignored.
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: "attacker.cluster",
EnableLogCollection: true,
})
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
// Flipping the toggles works with the pinned cluster echoed back (and
// with it omitted); the subdomain is never taken from the request.
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
// immutable fields must be ignored.
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: clusterAddr,
Cluster: "attacker.cluster",
Subdomain: "evil",
EnableLogCollection: true,
EnablePromptCollection: true,

View File

@@ -82,9 +82,6 @@ func (m *managerImpl) ValidateUserPermissions(
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
// ValidateRoleModuleAccess resolves an operation against the role's explicit
// grant for the module, then the grant for its parent module when the module
// is a dotted submodule, and finally the role's AutoAllowNew default.
func (m *managerImpl) ValidateRoleModuleAccess(
ctx context.Context,
accountID string,
@@ -92,7 +89,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
module modules.Module,
operation operations.Operation,
) bool {
if permissions, ok := lookupModulePermissions(role, module); ok {
if permissions, ok := role.Permissions[module]; ok {
if allowed, exists := permissions[operation]; exists {
return allowed
}
@@ -103,21 +100,6 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
// lookupModulePermissions returns the role's explicit permission set for the
// module, falling back to the parent module's set for dotted submodules. The
// second return reports whether any explicit set was found.
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
if permissions, ok := role.Permissions[module]; ok {
return permissions, true
}
if parent, hasParent := module.Parent(); hasParent {
if permissions, ok := role.Permissions[parent]; ok {
return permissions, true
}
}
return nil, false
}
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
return ctx, status.NewUserNotPartOfAccountError()
@@ -137,7 +119,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
permissions := roles.Permissions{}
for k := range modules.All {
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
if rolePermissions, ok := roleMap.Permissions[k]; ok {
permissions[k] = rolePermissions
continue
}

View File

@@ -1,139 +0,0 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
fullAccess := map[operations.Operation]bool{
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
}
readOnly := map[operations.Operation]bool{
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
denyAll := map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
t.Run("parent grant covers submodules", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
"parent full grant should allow create on a submodule")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"parent full grant should allow read on a submodule")
})
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"explicit submodule read should be allowed")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
"read-only submodule grant should not allow create")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
"submodule grant should not grant the parent module")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"submodule grant should not grant a sibling submodule")
})
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{
modules.AgentNetwork: fullAccess,
modules.AgentNetworkLogs: denyAll,
},
}
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"explicit submodule deny should override the parent grant")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"sibling submodules should still resolve through the parent grant")
})
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: readOnly,
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"auto-allow read should apply to submodules")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
"auto-allow should not grant unlisted operations")
})
}
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
// submodule split must not change: every built-in role resolves the new
// submodules exactly as it resolved the agent_network module before.
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
submodules := []modules.Module{
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
rolePermissions, ok := roles.RolesMap[role]
require.True(t, ok, "role %s must exist in RolesMap", role)
for _, sub := range submodules {
for _, op := range allOperations {
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
}
}
}
}
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
require.NoError(t, err, "auditor role must resolve")
usage, ok := permissions[modules.AgentNetworkUsage]
require.True(t, ok, "permissions map should contain the usage submodule")
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
require.NoError(t, err, "admin role must resolve")
providers, ok := adminPermissions[modules.AgentNetworkProviders]
require.True(t, ok, "permissions map should contain the providers submodule")
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
}

View File

@@ -1,7 +1,5 @@
package modules
import "strings"
type Module string
const (
@@ -22,17 +20,6 @@ const (
IdentityProviders Module = "identity_providers"
Services Module = "services"
AgentNetwork Module = "agent_network"
// Agent Network submodules. A role may grant one of these directly
// or grant the AgentNetwork parent, which covers all of them (see
// permissions.Manager cascade resolution).
AgentNetworkProviders Module = "agent_network.providers"
AgentNetworkPolicies Module = "agent_network.policies"
AgentNetworkGuardrails Module = "agent_network.guardrails"
AgentNetworkBudgets Module = "agent_network.budgets"
AgentNetworkUsage Module = "agent_network.usage"
AgentNetworkLogs Module = "agent_network.logs"
AgentNetworkSettings Module = "agent_network.settings"
)
var All = map[Module]struct{}{
@@ -53,21 +40,4 @@ var All = map[Module]struct{}{
IdentityProviders: {},
Services: {},
AgentNetwork: {},
AgentNetworkProviders: {},
AgentNetworkPolicies: {},
AgentNetworkGuardrails: {},
AgentNetworkBudgets: {},
AgentNetworkUsage: {},
AgentNetworkLogs: {},
AgentNetworkSettings: {},
}
// Parent returns the module owning a dotted submodule name and true, or the
// module itself and false when it has no parent.
func (m Module) Parent() (Module, bool) {
if i := strings.IndexByte(string(m), '.'); i > 0 {
return Module(string(m)[:i]), true
}
return m, false
}

View File

@@ -68,7 +68,7 @@ type ProxyAccessTokenGenerated struct {
// CreateNewProxyAccessToken generates a new proxy access token.
// Returns the token with hashed value stored and plain token for one-time display.
func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) {
hashedToken, plainToken, err := GenerateProxyToken()
hashedToken, plainToken, err := generateProxyToken()
if err != nil {
return nil, err
}
@@ -94,10 +94,7 @@ func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *
}, nil
}
// GenerateProxyToken generates a new random proxy token, returning its SHA-256
// hash (for storage) and the one-time plaintext. Exported so external modules
// can mint tokens in the canonical proxy-token format.
func GenerateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
secret, err := b.Random(ProxyTokenSecretLength)
if err != nil {
return "", "", err

View File

@@ -1,7 +1,6 @@
package types
import (
"strings"
"testing"
"time"
@@ -124,22 +123,6 @@ func TestCreateNewProxyAccessToken(t *testing.T) {
})
}
func TestGenerateProxyToken(t *testing.T) {
hashed, plain, err := GenerateProxyToken()
if err != nil {
t.Fatal(err)
}
if err := plain.Validate(); err != nil {
t.Errorf("generated token failed Validate(): %v", err)
}
if plain.Hash() != hashed {
t.Error("returned hashed token does not match Hash(plain)")
}
if !strings.HasPrefix(string(plain), ProxyTokenPrefix) {
t.Errorf("token %q missing prefix %q", plain, ProxyTokenPrefix)
}
}
func TestProxyAccessToken_IsExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour)
future := time.Now().Add(1 * time.Hour)

View File

@@ -30,23 +30,7 @@ mkdir -p /usr/local/bin/
$AGENT service install || true
$AGENT service start || true
console_user=$(stat -f%Su /dev/console 2>/dev/null)
case "$console_user" in
""|root|loginwindow|_mbsetupuser)
echo "No active GUI user session (console user: '${console_user:-none}'); skipping UI launch."
;;
*)
uid=$(id -u "$console_user" 2>/dev/null)
if [ -z "$uid" ]; then
echo "Could not resolve uid for console user '$console_user'; skipping UI launch."
else
echo "Launching NetBird UI as console user $console_user (uid $uid)."
if ! launchctl asuser "$uid" sudo -u "$console_user" -H open "$APP"; then
echo "Failed to launch NetBird UI; if autostart is enabled it will start at next login."
fi
fi
;;
esac
open $APP
echo "Finished Netbird installation successfully"
exit 0 # all good

View File

@@ -1,89 +0,0 @@
package client
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/encryption"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
)
// agentNetworkSetupServer is a minimal ManagementService that answers
// GetServerKey and GetAgentNetworkSetup with the real NaCl-box envelope,
// so the test pins the full wire path: gRPC method routing, request
// encryption, and response decryption.
type agentNetworkSetupServer struct {
mgmtProto.UnimplementedManagementServiceServer
key wgtypes.Key
}
func (s *agentNetworkSetupServer) GetServerKey(_ context.Context, _ *mgmtProto.Empty) (*mgmtProto.ServerKeyResponse, error) {
return &mgmtProto.ServerKeyResponse{Key: s.key.PublicKey().String()}, nil
}
func (s *agentNetworkSetupServer) GetAgentNetworkSetup(_ context.Context, msg *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
peerKey, err := wgtypes.ParseKey(msg.WgPubKey)
if err != nil {
return nil, err
}
req := &mgmtProto.AgentNetworkSetupRequest{}
if err := encryption.DecryptMessage(peerKey, s.key, msg.Body, req); err != nil {
return nil, err
}
resp := &mgmtProto.AgentNetworkSetupResponse{
Configured: true,
Endpoint: "https://violet.eu.proxy.example.com",
Providers: []*mgmtProto.AgentNetworkProviderInfo{
{
Name: "Anthropic prod",
CatalogId: "anthropic_api",
ApiFlavor: "anthropic",
Models: []string{"claude-sonnet-4-5"},
},
},
}
body, err := encryption.EncryptMessage(peerKey, s.key, resp)
if err != nil {
return nil, err
}
return &mgmtProto.EncryptedMessage{WgPubKey: s.key.PublicKey().String(), Body: body}, nil
}
// TestGetAgentNetworkSetup_RoundTrip proves the RPC is routable on any
// server built from this tree and that the encrypt→invoke→decrypt path
// the CLI uses round-trips. A server answering this call with
// codes.Unimplemented is by definition running a binary compiled without
// the regenerated management proto.
func TestGetAgentNetworkSetup_RoundTrip(t *testing.T) {
serverKey, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
srv := grpc.NewServer()
mgmtProto.RegisterManagementServiceServer(srv, &agentNetworkSetupServer{key: serverKey})
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
clientKey, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
client, err := NewClient(context.Background(), lis.Addr().String(), clientKey, false)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
setup, err := client.GetAgentNetworkSetup(context.Background())
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Equal(t, "https://violet.eu.proxy.example.com", setup.Endpoint)
require.Len(t, setup.Providers, 1)
assert.Equal(t, "anthropic_api", setup.Providers[0].CatalogId)
assert.Equal(t, []string{"claude-sonnet-4-5"}, setup.Providers[0].Models)
}

View File

@@ -22,6 +22,7 @@ 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.
@@ -34,8 +35,4 @@ type Client interface {
CreateExpose(ctx context.Context, req ExposeRequest) (*ExposeResponse, error)
RenewExpose(ctx context.Context, domain string) error
StopExpose(ctx context.Context, domain string) error
// GetAgentNetworkSetup returns the Agent Network connection info the
// calling peer's groups authorize: proxy endpoint plus effective
// providers and models.
GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error)
}

View File

@@ -436,6 +436,49 @@ 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)}
@@ -914,38 +957,6 @@ func (c *GrpcClient) StopExpose(ctx context.Context, domain string) error {
return err
}
// GetAgentNetworkSetup asks the management server for the Agent Network
// connection info the calling peer's groups authorize.
func (c *GrpcClient) GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error) {
serverPubKey, err := c.getServerPublicKey()
if err != nil {
return nil, err
}
encReq, err := encryption.EncryptMessage(*serverPubKey, c.key, &proto.AgentNetworkSetupRequest{})
if err != nil {
return nil, fmt.Errorf("encrypt agent network setup request: %w", err)
}
mgmCtx, cancel := context.WithTimeout(ctx, ConnectTimeout)
defer cancel()
resp, err := c.realClient.GetAgentNetworkSetup(mgmCtx, &proto.EncryptedMessage{
WgPubKey: c.key.PublicKey().String(),
Body: encReq,
})
if err != nil {
return nil, err
}
setupResp := &proto.AgentNetworkSetupResponse{}
if err := encryption.DecryptMessage(*serverPubKey, c.key, resp.Body, setupResp); err != nil {
return nil, fmt.Errorf("decrypt agent network setup response: %w", err)
}
return setupResp, nil
}
func fromProtoExposeResponse(resp *proto.ExposeServiceResponse) *ExposeResponse {
return &ExposeResponse{
ServiceName: resp.ServiceName,

View File

@@ -25,7 +25,6 @@ type MockClient struct {
CreateExposeFunc func(ctx context.Context, req ExposeRequest) (*ExposeResponse, error)
RenewExposeFunc func(ctx context.Context, domain string) error
StopExposeFunc func(ctx context.Context, domain string) error
GetAgentNetworkSetupFunc func(ctx context.Context) (*proto.AgentNetworkSetupResponse, error)
}
func (m *MockClient) IsHealthy() bool {
@@ -95,6 +94,11 @@ 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 {
@@ -137,10 +141,3 @@ func (m *MockClient) StopExpose(ctx context.Context, domain string) error {
}
return m.StopExposeFunc(ctx, domain)
}
func (m *MockClient) GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error) {
if m.GetAgentNetworkSetupFunc == nil {
return &proto.AgentNetworkSetupResponse{}, nil
}
return m.GetAgentNetworkSetupFunc(ctx)
}

View File

@@ -1,381 +0,0 @@
package rest
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// AgentNetworkAPI APIs for the Agent Network (AI/LLM gateway), do not use directly
// see more: https://docs.netbird.io/api/resources/agent-network
type AgentNetworkAPI struct {
c *Client
}
// ListCatalogProviders lists the catalog of supported upstream AI providers
// (openai_api, anthropic_api, bedrock_api, ...) with their default models and
// pricing, used to prefill provider create forms.
func (a *AgentNetworkAPI) ListCatalogProviders(ctx context.Context) ([]api.AgentNetworkCatalogProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/catalog/providers", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkCatalogProvider](resp)
return ret, err
}
// ListProviders lists all Agent Network providers
func (a *AgentNetworkAPI) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkProvider](resp)
return ret, err
}
// GetProvider gets Agent Network provider info
func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*api.AgentNetworkProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers/"+providerID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// CreateProvider creates a new Agent Network provider. Set
// request.BootstrapCluster on the account's first provider to bootstrap the
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
// with a cluster).
func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/providers", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// UpdateProvider updates an Agent Network provider. The request replaces the
// provider's mutable state; only an omitted api_key keeps the stored key
// (secrets are never required to round-trip).
func (a *AgentNetworkAPI) UpdateProvider(ctx context.Context, providerID string, request api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody) (*api.AgentNetworkProvider, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/providers/"+providerID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// DeleteProvider deletes an Agent Network provider. Fails while any policy
// still references the provider — detach it first.
func (a *AgentNetworkAPI) DeleteProvider(ctx context.Context, providerID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/providers/"+providerID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListPolicies lists all Agent Network policies
func (a *AgentNetworkAPI) ListPolicies(ctx context.Context) ([]api.AgentNetworkPolicy, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkPolicy](resp)
return ret, err
}
// GetPolicy gets Agent Network policy info
func (a *AgentNetworkAPI) GetPolicy(ctx context.Context, policyID string) (*api.AgentNetworkPolicy, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies/"+policyID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// CreatePolicy creates a new Agent Network policy
func (a *AgentNetworkAPI) CreatePolicy(ctx context.Context, request api.PostApiAgentNetworkPoliciesJSONRequestBody) (*api.AgentNetworkPolicy, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/policies", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// UpdatePolicy updates an Agent Network policy
func (a *AgentNetworkAPI) UpdatePolicy(ctx context.Context, policyID string, request api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody) (*api.AgentNetworkPolicy, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/policies/"+policyID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// DeletePolicy deletes an Agent Network policy
func (a *AgentNetworkAPI) DeletePolicy(ctx context.Context, policyID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/policies/"+policyID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListGuardrails lists all Agent Network guardrails
func (a *AgentNetworkAPI) ListGuardrails(ctx context.Context) ([]api.AgentNetworkGuardrail, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkGuardrail](resp)
return ret, err
}
// GetGuardrail gets Agent Network guardrail info
func (a *AgentNetworkAPI) GetGuardrail(ctx context.Context, guardrailID string) (*api.AgentNetworkGuardrail, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// CreateGuardrail creates a new Agent Network guardrail
func (a *AgentNetworkAPI) CreateGuardrail(ctx context.Context, request api.PostApiAgentNetworkGuardrailsJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/guardrails", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// UpdateGuardrail updates an Agent Network guardrail
func (a *AgentNetworkAPI) UpdateGuardrail(ctx context.Context, guardrailID string, request api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/guardrails/"+guardrailID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// DeleteGuardrail deletes an Agent Network guardrail
func (a *AgentNetworkAPI) DeleteGuardrail(ctx context.Context, guardrailID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListBudgetRules lists all account-level Agent Network budget rules
func (a *AgentNetworkAPI) ListBudgetRules(ctx context.Context) ([]api.AgentNetworkBudgetRule, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkBudgetRule](resp)
return ret, err
}
// GetBudgetRule gets Agent Network budget rule info
func (a *AgentNetworkAPI) GetBudgetRule(ctx context.Context, ruleID string) (*api.AgentNetworkBudgetRule, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// CreateBudgetRule creates a new Agent Network budget rule
func (a *AgentNetworkAPI) CreateBudgetRule(ctx context.Context, request api.PostApiAgentNetworkBudgetRulesJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/budget-rules", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// UpdateBudgetRule updates an Agent Network budget rule
func (a *AgentNetworkAPI) UpdateBudgetRule(ctx context.Context, ruleID string, request api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/budget-rules/"+ruleID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// DeleteBudgetRule deletes an Agent Network budget rule
func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// GetSettings gets the account's Agent Network gateway settings (cluster,
// subdomain, endpoint, collection toggles). An account that has not been
// bootstrapped yet — via UpdateSettings with a cluster, or by creating the
// first provider with bootstrap_cluster set — reads as the defaults with an
// empty Cluster, Subdomain and Endpoint. Management servers prior to that
// contract answered 200 with a JSON null body instead; that legacy shape is
// translated to an APIError matchable via IsNotFound rather than fabricating
// defaults the server never stated.
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
}
var ret api.AgentNetworkSettings
if err := json.Unmarshal(body, &ret); err != nil {
return nil, err
}
return &ret, nil
}
// UpdateSettings updates the account's Agent Network settings; the request
// replaces every mutable field (collection toggles and retention). Setting
// request.Cluster bootstraps the settings row when the account does not have
// one yet; on a bootstrapped account it must match the assigned cluster (or
// be nil) and any other value is rejected — the cluster is immutable.
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkSettings](resp)
return &ret, err
}

View File

@@ -1,497 +0,0 @@
//go:build integration
package rest_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
)
var (
testAgentNetworkProvider = api.AgentNetworkProvider{
Id: "ainp_test",
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
Models: []api.AgentNetworkProviderModel{},
Enabled: true,
}
testAgentNetworkPolicy = api.AgentNetworkPolicy{
Id: "ainpol_test",
Name: "Engineering → OpenAI",
Enabled: true,
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
}
testAgentNetworkGuardrail = api.AgentNetworkGuardrail{
Id: "aingr_test",
Name: "No secrets",
}
testAgentNetworkBudgetRule = api.AgentNetworkBudgetRule{
Id: "ainbud_test",
Name: "Org monthly ceiling",
Enabled: true,
}
testAgentNetworkSettings = api.AgentNetworkSettings{
Cluster: "eu.proxy.netbird.io",
Subdomain: "violet",
Endpoint: "violet.eu.proxy.netbird.io",
EnableLogCollection: true,
AccessLogRetentionDays: ptr(30),
}
)
func TestAgentNetwork_ListCatalogProviders_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/catalog/providers", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkCatalogProvider{{Id: "openai_api", Name: "OpenAI"}})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListCatalogProviders(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, "openai_api", ret[0].Id)
})
}
func TestAgentNetwork_ListProviders_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkProvider{testAgentNetworkProvider})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListProviders(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkProvider, ret[0])
})
}
func TestAgentNetwork_GetProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_GetProvider_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "not found", Code: 404})
w.WriteHeader(404)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
require.Error(t, err)
assert.True(t, rest.IsNotFound(err), "a 404 must be matchable via IsNotFound")
})
}
func TestAgentNetwork_CreateProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req api.PostApiAgentNetworkProvidersJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
assert.Equal(t, "OpenAI", req.Name)
require.NotNil(t, req.BootstrapCluster)
assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster)
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: ptr("sk-test"),
BootstrapCluster: ptr("eu.proxy.netbird.io"),
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_UpdateProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
// Omitted optional fields must be absent from the wire (not
// zero-valued) so the server-side merge preserves them.
assert.NotContains(t, string(reqBytes), "api_key")
assert.NotContains(t, string(reqBytes), "models")
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateProvider(context.Background(), "ainp_test", api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_DeleteProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteProvider(context.Background(), "ainp_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListPolicies_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkPolicy{testAgentNetworkPolicy})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListPolicies(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkPolicy, ret[0])
})
}
func TestAgentNetwork_GetPolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetPolicy(context.Background(), "ainpol_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_CreatePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreatePolicy(context.Background(), api.PostApiAgentNetworkPoliciesJSONRequestBody{
Name: "Engineering → OpenAI",
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_UpdatePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdatePolicy(context.Background(), "ainpol_test", api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody{
Name: "Engineering → OpenAI",
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_DeletePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeletePolicy(context.Background(), "ainpol_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListGuardrails_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkGuardrail{testAgentNetworkGuardrail})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListGuardrails(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkGuardrail, ret[0])
})
}
func TestAgentNetwork_GetGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetGuardrail(context.Background(), "aingr_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_CreateGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateGuardrail(context.Background(), api.PostApiAgentNetworkGuardrailsJSONRequestBody{
Name: "No secrets",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_UpdateGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateGuardrail(context.Background(), "aingr_test", api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody{
Name: "No secrets",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_DeleteGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteGuardrail(context.Background(), "aingr_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListBudgetRules_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkBudgetRule{testAgentNetworkBudgetRule})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListBudgetRules(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkBudgetRule, ret[0])
})
}
func TestAgentNetwork_GetBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetBudgetRule(context.Background(), "ainbud_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_CreateBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateBudgetRule(context.Background(), api.PostApiAgentNetworkBudgetRulesJSONRequestBody{
Name: "Org monthly ceiling",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_UpdateBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateBudgetRule(context.Background(), "ainbud_test", api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody{
Name: "Org monthly ceiling",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_DeleteBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteBudgetRule(context.Background(), "ainbud_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_GetSettings_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.NoError(t, err)
assert.Equal(t, testAgentNetworkSettings, *ret)
})
}
// TestAgentNetwork_GetSettings_UnbootstrappedDefaults pins the settings-read
// contract: an unbootstrapped account answers 200 with the defaults and empty
// cluster/subdomain/endpoint, which the client passes through untouched.
func TestAgentNetwork_GetSettings_UnbootstrappedDefaults(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(api.AgentNetworkSettings{
EnableLogCollection: true,
AccessLogRetentionDays: ptr(30),
})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.NoError(t, err)
assert.Empty(t, ret.Endpoint, "empty endpoint is the not-bootstrapped signal")
assert.True(t, ret.EnableLogCollection, "defaults must pass through")
})
}
func TestAgentNetwork_GetSettings_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "no", Code: 403})
w.WriteHeader(403)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.GetSettings(context.Background())
require.Error(t, err)
assert.Equal(t, "no", err.Error())
})
}
// TestAgentNetwork_GetSettings_LegacyNullBody pins the compatibility shim for
// management servers that answered 200 with a JSON null body before the
// defaults contract: the client translates that shape into an IsNotFound
// error instead of returning a bogus zero-valued settings object or
// fabricating defaults the server never stated.
func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("null"))
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.Error(t, err)
assert.Nil(t, ret)
assert.True(t, rest.IsNotFound(err), "the legacy 200+null shape must surface as IsNotFound")
})
}
func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req api.PutApiAgentNetworkSettingsJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire")
assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster)
assert.True(t, req.EnableLogCollection)
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Cluster: ptr("eu.proxy.netbird.io"),
EnableLogCollection: true,
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkSettings, *ret)
})
}
func TestAgentNetwork_UpdateSettings_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
w.WriteHeader(422)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Cluster: ptr("us.proxy.netbird.io"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "immutable")
})
}

View File

@@ -147,10 +147,6 @@ type Client struct {
// ReverseProxyTokens account-scoped proxy access tokens used to register
// self-hosted (bring-your-own-proxy) `netbird proxy` instances.
ReverseProxyTokens *ReverseProxyTokensAPI
// AgentNetwork NetBird Agent Network (AI/LLM gateway) APIs: catalog,
// providers, policies, guardrails, budget rules and account settings.
AgentNetwork *AgentNetworkAPI
}
// New initialize new Client instance using PAT token
@@ -213,7 +209,6 @@ func (c *Client) initialize() {
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
c.AgentNetwork = &AgentNetworkAPI{c}
}
// NewRequest creates and executes new management API request

View File

@@ -5149,12 +5149,12 @@ components:
identity_header_user_id:
type: string
description: |
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
example: "x-bf-dim-netbird_user_id"
identity_header_groups:
type: string
description: |
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
example: "x-bf-dim-netbird_groups"
enabled:
type: boolean
@@ -5186,8 +5186,6 @@ components:
- name
- upstream_url
- models
- identity_header_user_id
- identity_header_groups
- enabled
- skip_tls_verification
- metadata_disabled
@@ -5224,7 +5222,7 @@ components:
extra_values:
type: object
description: |
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
additionalProperties:
type: string
example:
@@ -5232,12 +5230,12 @@ components:
identity_header_user_id:
type: string
description: |
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
example: "x-bf-dim-netbird_user_id"
identity_header_groups:
type: string
description: |
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
example: "x-bf-dim-netbird_groups"
enabled:
type: boolean
@@ -5245,11 +5243,11 @@ components:
example: true
skip_tls_verification:
type: boolean
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
example: false
metadata_disabled:
type: boolean
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
example: false
required:
- provider_id
@@ -6193,19 +6191,19 @@ components:
- cache_cost_usd
AgentNetworkSettings:
type: object
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
properties:
cluster:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
example: "eu.proxy.netbird.io"
subdomain:
type: string
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
example: "violet"
endpoint:
type: string
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
example: "violet.eu.proxy.netbird.io"
enable_log_collection:
type: boolean
@@ -6226,13 +6224,13 @@ components:
created_at:
type: string
format: date-time
description: Timestamp when the settings row was created. Absent until the account is bootstrapped.
description: Timestamp when the settings row was created.
readOnly: true
example: "2026-04-26T10:30:00Z"
updated_at:
type: string
format: date-time
description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
description: Timestamp when the settings row was last updated.
readOnly: true
example: "2026-04-26T10:30:00Z"
required:
@@ -6242,14 +6240,12 @@ components:
- enable_log_collection
- enable_prompt_collection
- redact_pii
- created_at
- updated_at
AgentNetworkSettingsRequest:
type: object
description: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
properties:
cluster:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
example: "eu.proxy.netbird.io"
enable_log_collection:
type: boolean
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
@@ -13694,7 +13690,7 @@ paths:
/api/agent-network/settings:
get:
summary: Retrieve Agent Network settings
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint.
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13710,11 +13706,13 @@ paths:
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'500':
"$ref": "#/components/responses/internal_error"
put:
summary: Update Agent Network settings
description: Updates the account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable.
description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13740,8 +13738,6 @@ paths:
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'422':
"$ref": "#/components/responses/validation_failed"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/budget-rules:

View File

@@ -2275,11 +2275,11 @@ type AgentNetworkProvider struct {
// Id Provider ID
Id string `json:"id"`
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
IdentityHeaderGroups string `json:"identity_header_groups"`
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
IdentityHeaderUserId string `json:"identity_header_user_id"`
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
MetadataDisabled bool `json:"metadata_disabled"`
@@ -2335,16 +2335,16 @@ type AgentNetworkProviderRequest struct {
// Enabled Whether the provider is enabled. Defaults to true on create.
Enabled *bool `json:"enabled,omitempty"`
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
ExtraValues *map[string]string `json:"extra_values,omitempty"`
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
@@ -2356,22 +2356,22 @@ type AgentNetworkProviderRequest struct {
// ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom).
ProviderId string `json:"provider_id"`
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"`
// UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to.
UpstreamUrl string `json:"upstream_url"`
}
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
type AgentNetworkSettings struct {
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
Cluster string `json:"cluster"`
// CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped.
// CreatedAt Timestamp when the settings row was created.
CreatedAt *time.Time `json:"created_at,omitempty"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
@@ -2380,27 +2380,24 @@ type AgentNetworkSettings struct {
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
EnablePromptCollection bool `json:"enable_prompt_collection"`
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
Endpoint string `json:"endpoint"`
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
RedactPii bool `json:"redact_pii"`
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
Subdomain string `json:"subdomain"`
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
// UpdatedAt Timestamp when the settings row was last updated.
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
type AgentNetworkSettingsRequest struct {
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
Cluster *string `json:"cluster,omitempty"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
EnableLogCollection bool `json:"enable_log_collection"`

View File

@@ -228,17 +228,15 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
return c, nil
}
// decodeAccountNetwork never returns nil — Calculate() dereferences
// c.Network unconditionally, and servers that predate the fix omit the field
// entirely from the empty-components envelope.
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
n := &types.Network{}
if an == nil {
return n
return nil
}
n := &types.Network{
Identifier: an.Identifier,
Dns: an.Dns,
Serial: an.Serial,
}
n.Identifier = an.Identifier
n.Dns = an.Dns
n.Serial = an.Serial
if an.NetCidr != "" {
if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
n.Net = *ipnet

View File

@@ -221,66 +221,6 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
"client-side Calculate must connect the same remote peers as the server")
}
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
// the server takes for a peer that is missing from the account or absent from
// the validated-peers map. The legacy server short-circuited before
// Calculate() and shipped a NetworkMap carrying only the account Network; the
// components path runs Calculate() on the client instead, so the envelope must
// carry Network or the client panics dereferencing a nil *types.Network.
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
localPeerKey := randomWgKey(t)
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
PeerID: "peer-A",
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
Peers: map[string]*types.ComponentPeer{
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
},
})
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components")
require.Equal(t, uint64(7), result.NetworkMap.Serial)
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
}
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
// they survive talking to a management server that predates the encoder fix.
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
c, localPeerKey := buildSmokeComponents(t)
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
envelope.GetFull().Network = nil
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "a missing AccountNetwork must not panic the client")
require.NotNil(t, result.Components.Network)
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
}
// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1
// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient
// to validate the encode → marshal → decode → Calculate pipeline produces

View File

@@ -6653,201 +6653,6 @@ func (x *PeerIndexSet) GetPeerIndexes() []uint32 {
return nil
}
type AgentNetworkSetupRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *AgentNetworkSetupRequest) Reset() {
*x = AgentNetworkSetupRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_management_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *AgentNetworkSetupRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentNetworkSetupRequest) ProtoMessage() {}
func (x *AgentNetworkSetupRequest) ProtoReflect() protoreflect.Message {
mi := &file_management_proto_msgTypes[76]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AgentNetworkSetupRequest.ProtoReflect.Descriptor instead.
func (*AgentNetworkSetupRequest) Descriptor() ([]byte, []int) {
return file_management_proto_rawDescGZIP(), []int{76}
}
type AgentNetworkSetupResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// configured is false when the account has no Agent Network set up or
// the calling peer's groups authorize none of it — the two cases are
// deliberately indistinguishable.
Configured bool `protobuf:"varint,1,opt,name=configured,proto3" json:"configured,omitempty"`
// endpoint is the account's proxy base URL, e.g.
// "https://calm-otter.proxy.example.com". Reachable over the NetBird
// tunnel only. Empty when configured is false.
Endpoint string `protobuf:"bytes,2,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
Providers []*AgentNetworkProviderInfo `protobuf:"bytes,3,rep,name=providers,proto3" json:"providers,omitempty"`
}
func (x *AgentNetworkSetupResponse) Reset() {
*x = AgentNetworkSetupResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_management_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *AgentNetworkSetupResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentNetworkSetupResponse) ProtoMessage() {}
func (x *AgentNetworkSetupResponse) ProtoReflect() protoreflect.Message {
mi := &file_management_proto_msgTypes[77]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AgentNetworkSetupResponse.ProtoReflect.Descriptor instead.
func (*AgentNetworkSetupResponse) Descriptor() ([]byte, []int) {
return file_management_proto_rawDescGZIP(), []int{77}
}
func (x *AgentNetworkSetupResponse) GetConfigured() bool {
if x != nil {
return x.Configured
}
return false
}
func (x *AgentNetworkSetupResponse) GetEndpoint() string {
if x != nil {
return x.Endpoint
}
return ""
}
func (x *AgentNetworkSetupResponse) GetProviders() []*AgentNetworkProviderInfo {
if x != nil {
return x.Providers
}
return nil
}
type AgentNetworkProviderInfo struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// name is the operator-assigned provider label, e.g. "Bedrock prod".
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// catalog_id names the catalog entry, e.g. "anthropic_api", "bedrock_api".
CatalogId string `protobuf:"bytes,2,opt,name=catalog_id,json=catalogId,proto3" json:"catalog_id,omitempty"`
// api_flavor is the request-body shape the provider speaks ("anthropic",
// "openai"); empty when the proxy dispatches it by URL path instead.
ApiFlavor string `protobuf:"bytes,3,opt,name=api_flavor,json=apiFlavor,proto3" json:"api_flavor,omitempty"`
// all_models_allowed is true when no model allowlist restricts this
// provider for the caller; models then lists the declared/catalog models
// as a courtesy (possibly none for gateway-style providers).
AllModelsAllowed bool `protobuf:"varint,4,opt,name=all_models_allowed,json=allModelsAllowed,proto3" json:"all_models_allowed,omitempty"`
// models is the effective model allowlist for the caller (or the
// declared/catalog models when all_models_allowed is true).
Models []string `protobuf:"bytes,5,rep,name=models,proto3" json:"models,omitempty"`
}
func (x *AgentNetworkProviderInfo) Reset() {
*x = AgentNetworkProviderInfo{}
if protoimpl.UnsafeEnabled {
mi := &file_management_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *AgentNetworkProviderInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentNetworkProviderInfo) ProtoMessage() {}
func (x *AgentNetworkProviderInfo) ProtoReflect() protoreflect.Message {
mi := &file_management_proto_msgTypes[78]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AgentNetworkProviderInfo.ProtoReflect.Descriptor instead.
func (*AgentNetworkProviderInfo) Descriptor() ([]byte, []int) {
return file_management_proto_rawDescGZIP(), []int{78}
}
func (x *AgentNetworkProviderInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *AgentNetworkProviderInfo) GetCatalogId() string {
if x != nil {
return x.CatalogId
}
return ""
}
func (x *AgentNetworkProviderInfo) GetApiFlavor() string {
if x != nil {
return x.ApiFlavor
}
return ""
}
func (x *AgentNetworkProviderInfo) GetAllModelsAllowed() bool {
if x != nil {
return x.AllModelsAllowed
}
return false
}
func (x *AgentNetworkProviderInfo) GetModels() []string {
if x != nil {
return x.Models
}
return nil
}
type PortInfo_Range struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -6860,7 +6665,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
if protoimpl.UnsafeEnabled {
mi := &file_management_proto_msgTypes[80]
mi := &file_management_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6873,7 +6678,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
mi := &file_management_proto_msgTypes[80]
mi := &file_management_proto_msgTypes[77]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7939,127 +7744,99 @@ var file_management_proto_rawDesc = []byte{
0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x21, 0x0a,
0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73,
0x22, 0x1a, 0x0a, 0x18, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x9b, 0x01, 0x0a,
0x19, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74,
0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e,
0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e,
0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x42, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64,
0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52,
0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x22, 0xb2, 0x01, 0x0a, 0x18, 0x41,
0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x50, 0x72, 0x6f, 0x76, 0x69,
0x64, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63,
0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x70,
0x69, 0x5f, 0x66, 0x6c, 0x61, 0x76, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
0x61, 0x70, 0x69, 0x46, 0x6c, 0x61, 0x76, 0x6f, 0x72, 0x12, 0x2c, 0x0a, 0x12, 0x61, 0x6c, 0x6c,
0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18,
0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x61, 0x6c, 0x6c, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x73,
0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x6f, 0x64, 0x65, 0x6c,
0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2a,
0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e,
0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, 0x00,
0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, 0x12,
0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93, 0x01, 0x0a, 0x0e,
0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19,
0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79,
0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65,
0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50,
0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x49, 0x50, 0x76,
0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a, 0x21, 0x50, 0x65,
0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43, 0x6f, 0x6d, 0x70,
0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x10,
0x03, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07,
0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02,
0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d,
0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12,
0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06,
0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54,
0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04,
0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f,
0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50,
0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xa6, 0x08, 0x0a, 0x11,
0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63,
0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01,
0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79,
0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68,
0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74,
0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45,
0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a,
0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x10,
0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, 0x01,
0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93, 0x01, 0x0a,
0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12,
0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x50, 0x65,
0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19,
0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x49, 0x50,
0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a, 0x21, 0x50,
0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43, 0x6f, 0x6d,
0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70,
0x10, 0x03, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12,
0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10,
0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43,
0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05,
0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10,
0x06, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55,
0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a,
0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73,
0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50,
0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58,
0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a,
0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a,
0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a,
0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a,
0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e,
0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12,
0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b,
0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30,
0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65,
0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74,
0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65,
0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a,
0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43,
0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00,
0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75,
0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12,
0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78,
0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x54,
0x0a, 0x14, 0x47, 0x65, 0x74, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6b, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03,
0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41,
0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61,
0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45,
0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73,
0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42,
0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
}
var (
@@ -8075,7 +7852,7 @@ func file_management_proto_rawDescGZIP() []byte {
}
var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 86)
var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 83)
var file_management_proto_goTypes = []interface{}{
(JobStatus)(0), // 0: management.JobStatus
(PeerCapability)(0), // 1: management.PeerCapability
@@ -8161,18 +7938,15 @@ var file_management_proto_goTypes = []interface{}{
(*PolicyIds)(nil), // 81: management.PolicyIds
(*UserIDList)(nil), // 82: management.UserIDList
(*PeerIndexSet)(nil), // 83: management.PeerIndexSet
(*AgentNetworkSetupRequest)(nil), // 84: management.AgentNetworkSetupRequest
(*AgentNetworkSetupResponse)(nil), // 85: management.AgentNetworkSetupResponse
(*AgentNetworkProviderInfo)(nil), // 86: management.AgentNetworkProviderInfo
nil, // 87: management.SSHAuth.MachineUsersEntry
(*PortInfo_Range)(nil), // 88: management.PortInfo.Range
nil, // 89: management.NetworkMapComponentsFull.RoutersMapEntry
nil, // 90: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
nil, // 91: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
nil, // 92: management.NetworkMapComponentsFull.PostureFailedPeersEntry
nil, // 93: management.PolicyCompact.AuthorizedGroupsEntry
(*timestamppb.Timestamp)(nil), // 94: google.protobuf.Timestamp
(*durationpb.Duration)(nil), // 95: google.protobuf.Duration
nil, // 84: management.SSHAuth.MachineUsersEntry
(*PortInfo_Range)(nil), // 85: management.PortInfo.Range
nil, // 86: management.NetworkMapComponentsFull.RoutersMapEntry
nil, // 87: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
nil, // 88: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
nil, // 89: management.NetworkMapComponentsFull.PostureFailedPeersEntry
nil, // 90: management.PolicyCompact.AuthorizedGroupsEntry
(*timestamppb.Timestamp)(nil), // 91: google.protobuf.Timestamp
(*durationpb.Duration)(nil), // 92: google.protobuf.Duration
}
var file_management_proto_depIdxs = []int32{
11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
@@ -8184,7 +7958,7 @@ var file_management_proto_depIdxs = []int32{
39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
54, // 8: management.SyncResponse.Checks:type_name -> management.Checks
94, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
91, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
64, // 10: management.SyncResponse.NetworkMapEnvelope:type_name -> management.NetworkMapEnvelope
21, // 11: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
21, // 12: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
@@ -8197,10 +7971,10 @@ var file_management_proto_depIdxs = []int32{
27, // 19: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
34, // 20: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
54, // 21: management.LoginResponse.Checks:type_name -> management.Checks
94, // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
91, // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
21, // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
94, // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
94, // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
91, // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
91, // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
28, // 26: management.NetbirdConfig.stuns:type_name -> management.HostConfig
33, // 27: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
28, // 28: management.NetbirdConfig.signal:type_name -> management.HostConfig
@@ -8208,7 +7982,7 @@ var file_management_proto_depIdxs = []int32{
30, // 30: management.NetbirdConfig.flow:type_name -> management.FlowConfig
31, // 31: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig
6, // 32: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
95, // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
92, // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
28, // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
40, // 35: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
35, // 36: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
@@ -8221,7 +7995,7 @@ var file_management_proto_depIdxs = []int32{
56, // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
57, // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
37, // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
87, // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
84, // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
40, // 47: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
32, // 48: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
7, // 49: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
@@ -8235,7 +8009,7 @@ var file_management_proto_depIdxs = []int32{
4, // 57: management.FirewallRule.Action:type_name -> management.RuleAction
2, // 58: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
55, // 59: management.FirewallRule.PortInfo:type_name -> management.PortInfo
88, // 60: management.PortInfo.range:type_name -> management.PortInfo.Range
85, // 60: management.PortInfo.range:type_name -> management.PortInfo.Range
4, // 61: management.RouteFirewallRule.action:type_name -> management.RuleAction
2, // 62: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
55, // 63: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
@@ -8257,10 +8031,10 @@ var file_management_proto_depIdxs = []int32{
49, // 79: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord
48, // 80: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone
78, // 81: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw
89, // 82: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
90, // 83: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
91, // 84: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
92, // 85: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
86, // 82: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
87, // 83: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
88, // 84: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
89, // 85: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
66, // 86: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch
39, // 87: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig
39, // 88: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig
@@ -8270,52 +8044,49 @@ var file_management_proto_depIdxs = []int32{
57, // 92: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule
4, // 93: management.PolicyCompact.action:type_name -> management.RuleAction
2, // 94: management.PolicyCompact.protocol:type_name -> management.RuleProtocol
88, // 95: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
93, // 96: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
85, // 95: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
90, // 96: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
72, // 97: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
72, // 98: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
51, // 99: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
80, // 100: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
86, // 101: management.AgentNetworkSetupResponse.providers:type_name -> management.AgentNetworkProviderInfo
38, // 102: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
79, // 103: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
81, // 104: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
82, // 105: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
83, // 106: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
73, // 107: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
8, // 108: management.ManagementService.Login:input_type -> management.EncryptedMessage
8, // 109: management.ManagementService.Sync:input_type -> management.EncryptedMessage
26, // 110: management.ManagementService.GetServerKey:input_type -> management.Empty
26, // 111: management.ManagementService.isHealthy:input_type -> management.Empty
8, // 112: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
8, // 113: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
8, // 114: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
8, // 115: management.ManagementService.Logout:input_type -> management.EncryptedMessage
8, // 116: management.ManagementService.Job:input_type -> management.EncryptedMessage
8, // 117: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
8, // 118: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
8, // 119: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
8, // 120: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
8, // 121: management.ManagementService.GetAgentNetworkSetup:input_type -> management.EncryptedMessage
8, // 122: management.ManagementService.Login:output_type -> management.EncryptedMessage
8, // 123: management.ManagementService.Sync:output_type -> management.EncryptedMessage
25, // 124: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
26, // 125: management.ManagementService.isHealthy:output_type -> management.Empty
8, // 126: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
8, // 127: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
26, // 128: management.ManagementService.SyncMeta:output_type -> management.Empty
26, // 129: management.ManagementService.Logout:output_type -> management.Empty
8, // 130: management.ManagementService.Job:output_type -> management.EncryptedMessage
8, // 131: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
8, // 132: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
8, // 133: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
8, // 134: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
8, // 135: management.ManagementService.GetAgentNetworkSetup:output_type -> management.EncryptedMessage
122, // [122:136] is the sub-list for method output_type
108, // [108:122] is the sub-list for method input_type
108, // [108:108] is the sub-list for extension type_name
108, // [108:108] is the sub-list for extension extendee
0, // [0:108] is the sub-list for field type_name
38, // 101: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
79, // 102: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
81, // 103: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
82, // 104: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
83, // 105: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
73, // 106: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
8, // 107: management.ManagementService.Login:input_type -> management.EncryptedMessage
8, // 108: management.ManagementService.Sync:input_type -> management.EncryptedMessage
26, // 109: management.ManagementService.GetServerKey:input_type -> management.Empty
26, // 110: management.ManagementService.isHealthy:input_type -> management.Empty
8, // 111: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
8, // 112: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
8, // 113: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
8, // 114: management.ManagementService.Logout:input_type -> management.EncryptedMessage
8, // 115: management.ManagementService.Job:input_type -> management.EncryptedMessage
8, // 116: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
8, // 117: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
8, // 118: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
8, // 119: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
8, // 120: management.ManagementService.Login:output_type -> management.EncryptedMessage
8, // 121: management.ManagementService.Sync:output_type -> management.EncryptedMessage
25, // 122: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
26, // 123: management.ManagementService.isHealthy:output_type -> management.Empty
8, // 124: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
8, // 125: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
26, // 126: management.ManagementService.SyncMeta:output_type -> management.Empty
26, // 127: management.ManagementService.Logout:output_type -> management.Empty
8, // 128: management.ManagementService.Job:output_type -> management.EncryptedMessage
8, // 129: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
8, // 130: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
8, // 131: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
8, // 132: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
120, // [120:133] is the sub-list for method output_type
107, // [107:120] is the sub-list for method input_type
107, // [107:107] is the sub-list for extension type_name
107, // [107:107] is the sub-list for extension extendee
0, // [0:107] is the sub-list for field type_name
}
func init() { file_management_proto_init() }
@@ -9236,43 +9007,7 @@ func file_management_proto_init() {
return nil
}
}
file_management_proto_msgTypes[76].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AgentNetworkSetupRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_management_proto_msgTypes[77].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AgentNetworkSetupResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_management_proto_msgTypes[78].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AgentNetworkProviderInfo); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_management_proto_msgTypes[80].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*PortInfo_Range); i {
case 0:
return &v.state
@@ -9305,7 +9040,7 @@ func file_management_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_management_proto_rawDesc,
NumEnums: 8,
NumMessages: 86,
NumMessages: 83,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -68,13 +68,6 @@ service ManagementService {
// StopExpose terminates an active expose session
rpc StopExpose(EncryptedMessage) returns (EncryptedMessage) {}
// GetAgentNetworkSetup returns the Agent Network connection info the
// calling peer's groups authorize: proxy endpoint plus effective
// providers and models. Caller-scoped by the peer's WireGuard key.
// EncryptedMessage of the request has a body of AgentNetworkSetupRequest.
// EncryptedMessage of the response has a body of AgentNetworkSetupResponse.
rpc GetAgentNetworkSetup(EncryptedMessage) returns (EncryptedMessage) {}
}
message EncryptedMessage {
@@ -1214,34 +1207,3 @@ message UserIDList {
message PeerIndexSet {
repeated uint32 peer_indexes = 1;
}
message AgentNetworkSetupRequest {}
message AgentNetworkSetupResponse {
// configured is false when the account has no Agent Network set up or
// the calling peer's groups authorize none of it — the two cases are
// deliberately indistinguishable.
bool configured = 1;
// endpoint is the account's proxy base URL, e.g.
// "https://calm-otter.proxy.example.com". Reachable over the NetBird
// tunnel only. Empty when configured is false.
string endpoint = 2;
repeated AgentNetworkProviderInfo providers = 3;
}
message AgentNetworkProviderInfo {
// name is the operator-assigned provider label, e.g. "Bedrock prod".
string name = 1;
// catalog_id names the catalog entry, e.g. "anthropic_api", "bedrock_api".
string catalog_id = 2;
// api_flavor is the request-body shape the provider speaks ("anthropic",
// "openai"); empty when the proxy dispatches it by URL path instead.
string api_flavor = 3;
// all_models_allowed is true when no model allowlist restricts this
// provider for the caller; models then lists the declared/catalog models
// as a courtesy (possibly none for gateway-style providers).
bool all_models_allowed = 4;
// models is the effective model allowlist for the caller (or the
// declared/catalog models when all_models_allowed is true).
repeated string models = 5;
}

View File

@@ -65,12 +65,6 @@ type ManagementServiceClient interface {
RenewExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// StopExpose terminates an active expose session
StopExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// GetAgentNetworkSetup returns the Agent Network connection info the
// calling peer's groups authorize: proxy endpoint plus effective
// providers and models. Caller-scoped by the peer's WireGuard key.
// EncryptedMessage of the request has a body of AgentNetworkSetupRequest.
// EncryptedMessage of the response has a body of AgentNetworkSetupResponse.
GetAgentNetworkSetup(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
}
type managementServiceClient struct {
@@ -243,15 +237,6 @@ func (c *managementServiceClient) StopExpose(ctx context.Context, in *EncryptedM
return out, nil
}
func (c *managementServiceClient) GetAgentNetworkSetup(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) {
out := new(EncryptedMessage)
err := c.cc.Invoke(ctx, "/management.ManagementService/GetAgentNetworkSetup", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// ManagementServiceServer is the server API for ManagementService service.
// All implementations must embed UnimplementedManagementServiceServer
// for forward compatibility
@@ -303,12 +288,6 @@ type ManagementServiceServer interface {
RenewExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// StopExpose terminates an active expose session
StopExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// GetAgentNetworkSetup returns the Agent Network connection info the
// calling peer's groups authorize: proxy endpoint plus effective
// providers and models. Caller-scoped by the peer's WireGuard key.
// EncryptedMessage of the request has a body of AgentNetworkSetupRequest.
// EncryptedMessage of the response has a body of AgentNetworkSetupResponse.
GetAgentNetworkSetup(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
mustEmbedUnimplementedManagementServiceServer()
}
@@ -355,9 +334,6 @@ func (UnimplementedManagementServiceServer) RenewExpose(context.Context, *Encryp
func (UnimplementedManagementServiceServer) StopExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopExpose not implemented")
}
func (UnimplementedManagementServiceServer) GetAgentNetworkSetup(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetAgentNetworkSetup not implemented")
}
func (UnimplementedManagementServiceServer) mustEmbedUnimplementedManagementServiceServer() {}
// UnsafeManagementServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -616,24 +592,6 @@ func _ManagementService_StopExpose_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _ManagementService_GetAgentNetworkSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EncryptedMessage)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ManagementServiceServer).GetAgentNetworkSetup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/management.ManagementService/GetAgentNetworkSetup",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ManagementServiceServer).GetAgentNetworkSetup(ctx, req.(*EncryptedMessage))
}
return interceptor(ctx, in, info, handler)
}
// ManagementService_ServiceDesc is the grpc.ServiceDesc for ManagementService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -685,10 +643,6 @@ var ManagementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "StopExpose",
Handler: _ManagementService_StopExpose_Handler,
},
{
MethodName: "GetAgentNetworkSetup",
Handler: _ManagementService_GetAgentNetworkSetup_Handler,
},
},
Streams: []grpc.StreamDesc{
{