mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-31 12:51:28 +02:00
Compare commits
6 Commits
android/gu
...
agent-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b523a6d1d6 | ||
|
|
a5b27579ec | ||
|
|
2b7703a54f | ||
|
|
ac1e909de9 | ||
|
|
e6a286ab67 | ||
|
|
2316c20a2b |
89
.github/workflows/test-infrastructure-files.yml
vendored
89
.github/workflows/test-infrastructure-files.yml
vendored
@@ -249,35 +249,78 @@ jobs:
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
|
||||
|
||||
test-legacy-getting-started-scripts:
|
||||
test-getting-started-script:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Dex installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Dex support is not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
- name: run script with Zitadel PostgreSQL
|
||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
|
||||
- name: Verify Zitadel retirement notice
|
||||
- name: test Caddy file gen postgres
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen postgres
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen postgres
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen postgres
|
||||
run: |
|
||||
if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Zitadel installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
|
||||
- name: test zitadel.env file gen postgres
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen postgres
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen postgres
|
||||
run: test -f relay.env
|
||||
|
||||
- name: test zdb.env file gen postgres
|
||||
run: test -f zdb.env
|
||||
|
||||
- name: Postgres run cleanup
|
||||
run: |
|
||||
docker compose down --volumes --rmi all
|
||||
rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env
|
||||
|
||||
- name: run script with Zitadel CockroachDB
|
||||
run: bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
env:
|
||||
NETBIRD_DOMAIN: use-ip
|
||||
ZITADEL_DATABASE: cockroach
|
||||
|
||||
- name: test Caddy file gen CockroachDB
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen CockroachDB
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen CockroachDB
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen CockroachDB
|
||||
run: |
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
|
||||
- name: test zitadel.env file gen CockroachDB
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen CockroachDB
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen CockroachDB
|
||||
run: test -f relay.env
|
||||
|
||||
@@ -234,22 +234,12 @@ cd client/ui
|
||||
task dev
|
||||
```
|
||||
|
||||
Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
|
||||
Pass daemon flags after `--`:
|
||||
|
||||
```
|
||||
task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
|
||||
task dev -- --daemon-addr=npipe://netbird # Windows
|
||||
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
||||
```
|
||||
|
||||
On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
|
||||
ends up being depends on what the daemon may create: as a service or elevated it
|
||||
serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
|
||||
process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
|
||||
Clients try both and check who owns the pipe before using the plain one. Avoid
|
||||
`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
|
||||
refuses the operations that require an administrator and you will not exercise
|
||||
those paths.
|
||||
|
||||
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
|
||||
|
||||
```
|
||||
|
||||
@@ -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,34 +76,13 @@ 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
|
||||
eventSub *peer.EventSubscription
|
||||
// Closed to stop the watch goroutines from delivering buffered items to a
|
||||
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
||||
stateChangeDone chan struct{}
|
||||
|
||||
// Latched "the server wants an interactive login": survives the engine
|
||||
// restarts that replace the run loop's context state. See Client.Status.
|
||||
// Guarded by loginRequiredMu together with loginCleared, which counts
|
||||
// clears so a stale observation cannot re-latch over one.
|
||||
loginRequiredMu sync.Mutex
|
||||
loginRequired bool
|
||||
loginCleared uint64
|
||||
|
||||
extendMu sync.Mutex
|
||||
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 +92,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,21 +144,16 @@ 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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()
|
||||
// still reads the previous run's context state, which holds the NeedsLogin
|
||||
// that prompted this login, and would re-latch what was just cleared.
|
||||
c.clearLoginRequired()
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -230,7 +188,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 +217,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) {
|
||||
|
||||
@@ -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"
|
||||
@@ -55,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,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)
|
||||
@@ -170,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// StateChangeListener receives client state notifications.
|
||||
//
|
||||
// OnStateChanged is a payload-free wake-up whenever the state snapshot
|
||||
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
||||
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
||||
// trigger — on each signal the consumer pulls the fresh values via
|
||||
// Status() / SessionExpiresAtUnix().
|
||||
//
|
||||
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
|
||||
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
|
||||
// (finalWarning true). The second one is suppressed when the user dismissed
|
||||
// the first via DismissSessionWarning. The daemon turns the same events into
|
||||
// its tray notification.
|
||||
type StateChangeListener interface {
|
||||
OnStateChanged()
|
||||
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
||||
}
|
||||
|
||||
// Status returns the connect run-loop's status label — the same value the
|
||||
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
|
||||
// management server rejected the peer and an interactive login is required.
|
||||
//
|
||||
// The label is latched: the run loop keeps its status in a per-run context
|
||||
// state, which a restart replaces with a fresh Idle one, so an engine restart
|
||||
// (network change, always-on) would otherwise erase the fact that the peer
|
||||
// still needs to log in. Only a successful interactive login or extend clears
|
||||
// it — see clearLoginRequired.
|
||||
func (c *Client) Status() string {
|
||||
latched, generation := c.loginRequiredState()
|
||||
if latched {
|
||||
return string(internal.StatusNeedsLogin)
|
||||
}
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return string(internal.StatusIdle)
|
||||
}
|
||||
status := cc.Status()
|
||||
if status == internal.StatusNeedsLogin {
|
||||
c.latchLoginRequired(generation)
|
||||
}
|
||||
return string(status)
|
||||
}
|
||||
|
||||
func (c *Client) loginRequiredState() (bool, uint64) {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
return c.loginRequired, c.loginCleared
|
||||
}
|
||||
|
||||
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
|
||||
// while the caller was reading the run loop's status: cc.Status() is read
|
||||
// outside the lock, so a login or extend completing in that window would
|
||||
// otherwise be undone by this stale observation, stranding the UI on
|
||||
// "login required" over a healthy session.
|
||||
func (c *Client) latchLoginRequired(observedGeneration uint64) {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
if c.loginCleared != observedGeneration {
|
||||
return
|
||||
}
|
||||
c.loginRequired = true
|
||||
}
|
||||
|
||||
// clearLoginRequired releases the latch after a successful interactive login
|
||||
// or session extend, and invalidates any observation already in flight.
|
||||
func (c *Client) clearLoginRequired() {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
c.loginRequired = false
|
||||
c.loginCleared++
|
||||
}
|
||||
|
||||
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
|
||||
// when no deadline is known (not SSO-registered, expiry disabled, or the
|
||||
// engine has not received one yet). A past value means the session expired.
|
||||
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
|
||||
func (c *Client) SessionExpiresAtUnix() int64 {
|
||||
deadline := c.recorder.GetSessionExpiresAt()
|
||||
if deadline.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return deadline.Unix()
|
||||
}
|
||||
|
||||
// SetStateChangeListener registers the state notification listener.
|
||||
// Replaces any previously registered listener; remove it with
|
||||
// RemoveStateChangeListener.
|
||||
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
||||
c.stateChangeMu.Lock()
|
||||
defer c.stateChangeMu.Unlock()
|
||||
c.stopStateChangeWatchLocked()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Both subscriptions are buffered (one pending tick, ten pending events),
|
||||
// so unsubscribing is not enough to stop callbacks: the loops would drain
|
||||
// what is already queued and deliver it to a listener the caller has
|
||||
// already removed or replaced. Gate every callback on this registration's
|
||||
// own signal, which is closed before unsubscribing.
|
||||
done := make(chan struct{})
|
||||
c.stateChangeDone = done
|
||||
|
||||
id, ch := c.recorder.SubscribeToStateChanges()
|
||||
c.stateChangeSubID = id
|
||||
// The channel is closed by UnsubscribeFromStateChanges, which ends the
|
||||
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
|
||||
// wakes the listener once.
|
||||
go func() {
|
||||
for range ch {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
listener.OnStateChanged()
|
||||
}
|
||||
}()
|
||||
|
||||
c.eventSub = c.recorder.SubscribeToEvents()
|
||||
go watchSessionWarnings(c.eventSub, listener, done)
|
||||
}
|
||||
|
||||
// RemoveStateChangeListener unregisters the state notification listener.
|
||||
func (c *Client) RemoveStateChangeListener() {
|
||||
c.stateChangeMu.Lock()
|
||||
defer c.stateChangeMu.Unlock()
|
||||
c.stopStateChangeWatchLocked()
|
||||
}
|
||||
|
||||
// DismissSessionWarning records the user's "Dismiss" on the first expiry
|
||||
// warning and suppresses the final one for the current deadline. A refreshed
|
||||
// deadline re-arms both. No-op while the engine is not running.
|
||||
func (c *Client) DismissSessionWarning() {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return
|
||||
}
|
||||
engine.DismissSessionWarning()
|
||||
}
|
||||
|
||||
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
|
||||
// asks the management server to extend the session deadline. The tunnel is
|
||||
// untouched: no resync, no reconnect. Async; the result arrives on the
|
||||
// listener. Mirror of the daemon's RequestExtendAuthSession /
|
||||
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
|
||||
// browser" role.
|
||||
//
|
||||
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
|
||||
// so a second concurrent flow would fail on that bind. Call
|
||||
// CancelExtendAuthSession when the user abandons the browser.
|
||||
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
|
||||
ctx, err := c.beginExtend()
|
||||
if err != nil {
|
||||
resultListener.OnError(err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer c.endExtend()
|
||||
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
|
||||
resultListener.OnError(err)
|
||||
return
|
||||
}
|
||||
resultListener.OnSuccess()
|
||||
}()
|
||||
}
|
||||
|
||||
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
|
||||
// left alone — unlike the login flow, which cancels the whole client context
|
||||
// by stopping the engine. Without this the abandoned PKCE wait keeps its
|
||||
// loopback port for the full flow timeout and blocks every later attempt.
|
||||
// No-op when no flow is running.
|
||||
func (c *Client) CancelExtendAuthSession() {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
c.extendCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) stopStateChangeWatchLocked() {
|
||||
// Signal first, unsubscribe second: closing the channels only stops new
|
||||
// items, and the loops would still hand whatever is buffered to a listener
|
||||
// that is no longer registered.
|
||||
if c.stateChangeDone != nil {
|
||||
close(c.stateChangeDone)
|
||||
c.stateChangeDone = nil
|
||||
}
|
||||
if c.stateChangeSubID != "" {
|
||||
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
|
||||
c.stateChangeSubID = ""
|
||||
}
|
||||
if c.eventSub != nil {
|
||||
// Closes the channel, which ends watchSessionWarnings.
|
||||
c.recorder.UnsubscribeFromEvents(c.eventSub)
|
||||
c.eventSub = nil
|
||||
}
|
||||
}
|
||||
|
||||
// watchSessionWarnings forwards the engine's session-expiry warnings to the
|
||||
// listener. The event stream also carries unrelated traffic — network-map
|
||||
// updates on every sync, DNS and route errors — so everything but an
|
||||
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
|
||||
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
|
||||
// done is closed — the stream buffers up to ten events, and a deregistered
|
||||
// listener must not receive the ones already queued.
|
||||
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
|
||||
for ev := range sub.Events() {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
|
||||
continue
|
||||
}
|
||||
meta := ev.GetMetadata()
|
||||
if meta[sessionwatch.MetaSessionWarning] != "true" {
|
||||
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
|
||||
// out of range); they carry no warning marker.
|
||||
continue
|
||||
}
|
||||
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
|
||||
if err != nil {
|
||||
log.Warnf("session warning event with unparsable deadline: %v", err)
|
||||
continue
|
||||
}
|
||||
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
|
||||
if err != nil {
|
||||
// Informational only — the deadline above is what drives the UI.
|
||||
lead = 0
|
||||
}
|
||||
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
|
||||
meta[sessionwatch.MetaSessionFinal] == "true")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) beginExtend() (context.Context, error) {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
return nil, fmt.Errorf("session extend already in progress")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.extendCancel = cancel
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (c *Client) endExtend() {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
c.extendCancel()
|
||||
c.extendCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return fmt.Errorf("engine is not initialized")
|
||||
}
|
||||
|
||||
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth client: %v", err)
|
||||
}
|
||||
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)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
|
||||
return err
|
||||
}
|
||||
c.clearLoginRequired()
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
return nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonCallError prepares a daemon error for display. A refusal the daemon
|
||||
// raised because the operation needs root/administrator is already guidance
|
||||
// written for the user, so it is surfaced on its own instead of buried under the
|
||||
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
|
||||
// with context as usual.
|
||||
func daemonCallError(context string, err error) error {
|
||||
if guidance, ok := privilegeGuidance(err); ok {
|
||||
return errors.New(guidance)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", context, err)
|
||||
}
|
||||
|
||||
// privilegeGuidance renders the daemon's privilege refusal as a summary and the
|
||||
// command that performs the operation with the privileges it needs. It reports
|
||||
// false for any other error.
|
||||
func privilegeGuidance(err error) (string, bool) {
|
||||
info, ok := privilegeErrorInfo(err)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
|
||||
if summary == "" {
|
||||
// Detail without a summary: fall back to the status message, which
|
||||
// carries the same text.
|
||||
summary = strings.TrimSpace(gstatus.Convert(err).Message())
|
||||
}
|
||||
if command == "" {
|
||||
return summary, true
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s\n\n %s\n", summary, command), true
|
||||
}
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if _, err := daemonClient.Logout(ctx, req); err != nil {
|
||||
return daemonCallError("deregister", err)
|
||||
return fmt.Errorf("deregister: %v", err)
|
||||
}
|
||||
|
||||
cmd.Println("Deregistered successfully")
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -90,7 +91,6 @@ var (
|
||||
// Don't resolve for service commands — they create the socket, not connect to it.
|
||||
if !isServiceCmd(cmd) {
|
||||
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
|
||||
daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -143,10 +143,10 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = daddr.WindowsPipeAddr
|
||||
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
@@ -269,10 +269,12 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
target, opts := daddr.DialTarget(addr)
|
||||
opts = append(opts, grpc.WithBlock())
|
||||
|
||||
return grpc.DialContext(ctx, target, opts...)
|
||||
return grpc.DialContext(
|
||||
ctx,
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -33,15 +33,10 @@ var (
|
||||
)
|
||||
|
||||
type program struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
// jsonClient is the gateway's own connection to the daemon. It is held so
|
||||
// shutting the gateway down also closes it: nothing else references it once
|
||||
// the handlers are registered, so its transport goroutines would otherwise
|
||||
// outlive the server.
|
||||
jsonClient *grpc.ClientConn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
jsonServMu sync.Mutex
|
||||
serverInstance *server.Server
|
||||
serverInstanceMu sync.Mutex
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -14,8 +13,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -29,31 +26,6 @@ func validateJSONSocketFlags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonServerOptions installs the transport credentials that expose each
|
||||
// caller's kernel-authenticated identity to the handlers, which is what lets
|
||||
// the daemon require root/administrator for privileged operations.
|
||||
//
|
||||
// The handshake exchanges no bytes, so older CLI and UI binaries still
|
||||
// interoperate. Callers on a TCP socket carry no identity at all: the daemon
|
||||
// keeps serving them, and the privileged operations deny them, so a warning is
|
||||
// logged to make the loss of functionality visible.
|
||||
func daemonServerOptions(network string) []grpc.ServerOption {
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
|
||||
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
return []grpc.ServerOption{grpc.Creds(creds)}
|
||||
}
|
||||
|
||||
func (p *program) Start(svc service.Service) error {
|
||||
// Start should not block. Do the actual work async.
|
||||
log.Info("starting NetBird service") //nolint
|
||||
@@ -65,106 +37,68 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// A daemon installed before named-pipe support has the loopback TCP address
|
||||
// persisted. Move it to the named pipe so an upgraded daemon can identify
|
||||
// its callers instead of silently serving an unauthenticated socket.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
|
||||
network, _, err := parseListenAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network)...)
|
||||
p.serv = grpc.NewServer()
|
||||
|
||||
daemonListener, jsonListener, err := listenDaemonSockets()
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("listen daemon interface: %w", err)
|
||||
}
|
||||
|
||||
var jsonListener *socketListener
|
||||
if enableJSONSocket {
|
||||
jsonListener, err = listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
_ = daemonListener.Close()
|
||||
return fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
} else {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Fatal here rather than inside serve, so serve's deferred listener
|
||||
// closes run before the process exits.
|
||||
if err := p.serve(daemonListener, jsonListener); err != nil {
|
||||
log.Fatalf("failed to %v", err)
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
if jsonListener != nil {
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
|
||||
// JSON gateway socket. The control socket is closed again if the second one fails,
|
||||
// so a failed start leaves nothing listening. The returned JSON listener is nil
|
||||
// when the socket is disabled.
|
||||
func listenDaemonSockets() (*socketListener, *socketListener, error) {
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
|
||||
}
|
||||
|
||||
if !enableJSONSocket {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
return daemonListener, nil, nil
|
||||
}
|
||||
|
||||
jsonListener, err := listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
if cerr := daemonListener.Close(); cerr != nil {
|
||||
log.Debugf("close daemon listener: %v", cerr)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
|
||||
return daemonListener, jsonListener, nil
|
||||
}
|
||||
|
||||
// serve brings up the daemon server on an already-open control socket and blocks
|
||||
// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
|
||||
// error means the daemon cannot run at all and the caller is expected to exit; the
|
||||
// failures it recovers from on its own are logged here.
|
||||
func (p *program) serve(daemonListener, jsonListener *socketListener) error {
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
// chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener == nil {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
} else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
return fmt.Errorf("start daemon JSON server: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) Stop(srv service.Service) error {
|
||||
p.serverInstanceMu.Lock()
|
||||
if p.serverInstance != nil {
|
||||
@@ -179,13 +113,8 @@ func (p *program) Stop(srv service.Service) error {
|
||||
p.cancel()
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
jsonServ, jsonClient := p.jsonServ, p.jsonClient
|
||||
jsonServ := p.jsonServ
|
||||
p.jsonServMu.Unlock()
|
||||
if jsonClient != nil {
|
||||
if err := jsonClient.Close(); err != nil {
|
||||
log.Debugf("close daemon JSON gateway client: %v", err)
|
||||
}
|
||||
}
|
||||
if jsonServ != nil {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := jsonServ.Shutdown(shutdownCtx); err != nil {
|
||||
|
||||
@@ -5,123 +5,27 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// jsonPeerIdentity is the context key under which the connecting HTTP client's
|
||||
// identity is stashed for the lifetime of its connection.
|
||||
type jsonPeerIdentity struct{}
|
||||
|
||||
// jsonPeerIdentityValue pairs the identity with whether it could be read at
|
||||
// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
|
||||
type jsonPeerIdentityValue struct {
|
||||
id ipcauth.Identity
|
||||
known bool
|
||||
}
|
||||
|
||||
// jsonConnContext reads the identity of the client connecting to the JSON
|
||||
// socket and stashes it on the connection's context. The gateway re-dials the
|
||||
// daemon in-process, so the daemon would otherwise see every JSON request as
|
||||
// coming from the daemon itself.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
value := jsonPeerIdentityValue{}
|
||||
id, err := ipcauth.ConnIdentity(c)
|
||||
if err != nil {
|
||||
log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
|
||||
} else {
|
||||
value.id = id
|
||||
value.known = true
|
||||
}
|
||||
return context.WithValue(ctx, jsonPeerIdentity{}, value)
|
||||
}
|
||||
|
||||
// forwardIdentity stamps the HTTP client's identity onto every call the gateway
|
||||
// makes to the daemon.
|
||||
//
|
||||
// It is an interceptor on the gateway's client connection rather than a
|
||||
// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
|
||||
// request header maps to metadata, which an HTTP/1.0 request with no Host header
|
||||
// over a unix socket achieves. The daemon would then receive no marker, see its own
|
||||
// identity as the transport peer, and authorize the request as the daemon itself.
|
||||
// An interceptor runs for every RPC whatever the request looked like.
|
||||
func forwardIdentity(ctx context.Context) context.Context {
|
||||
value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
|
||||
if !ok {
|
||||
// No ConnContext ran for this request, so forward an unknown identity:
|
||||
// the daemon must not mistake its own identity for the client's.
|
||||
return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
|
||||
}
|
||||
return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
|
||||
}
|
||||
|
||||
func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
|
||||
}
|
||||
|
||||
func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
|
||||
}
|
||||
|
||||
// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
|
||||
var reservedHeaderWarning sync.Once
|
||||
|
||||
// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
|
||||
// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-<key>"
|
||||
// headers into gRPC metadata and joins them ahead of what its annotators add, so
|
||||
// without this filter a JSON client could send its own x-netbird-fwd-uid and the
|
||||
// daemon would authorize that instead of the client's real identity.
|
||||
func jsonIncomingHeaderMatcher(key string) (string, bool) {
|
||||
mapped, ok := runtime.DefaultHeaderMatcher(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if ipcauth.IsReservedForwardKey(mapped) {
|
||||
// Warn once: any client can send these on every request, so warning each
|
||||
// time hands it a way to fill the log. The rest are debug-level.
|
||||
reservedHeaderWarning.Do(func() {
|
||||
log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
|
||||
})
|
||||
log.Debugf("json gateway: dropping reserved header %q", key)
|
||||
return "", false
|
||||
}
|
||||
return mapped, true
|
||||
func grpcGatewayEndpoint(addr string) string {
|
||||
return strings.TrimPrefix(addr, "tcp://")
|
||||
}
|
||||
|
||||
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||
if jsonListener.network == "tcp" {
|
||||
log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations will be denied for JSON clients", jsonListener.address)
|
||||
}
|
||||
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
// grpc.NewClient does not connect until the first request, so registering
|
||||
// the handler here cannot block daemon startup.
|
||||
target, opts := daemonaddr.DialTarget(daemonEndpoint)
|
||||
opts = append(opts,
|
||||
grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
|
||||
grpc.WithChainStreamInterceptor(forwardIdentityStream),
|
||||
)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
|
||||
}
|
||||
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
|
||||
}
|
||||
mux := runtime.NewServeMux()
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -131,12 +35,10 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return p.ctx
|
||||
},
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
p.jsonServ = jsonServer
|
||||
p.jsonClient = conn
|
||||
p.jsonServMu.Unlock()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
|
||||
// request reaches a handler with the daemon's own identity as the transport peer.
|
||||
// The gateway therefore forwards its HTTP client's identity as metadata, and the
|
||||
// daemon authorizes that instead of itself. These tests drive the real wiring
|
||||
// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
|
||||
// identity a handler would end up authorizing.
|
||||
|
||||
// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
|
||||
// peer must be this process's own identity: the gateway is the daemon, so the two
|
||||
// cannot differ, and hardcoding root here instead would describe a state that
|
||||
// never occurs.
|
||||
func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
|
||||
t.Helper()
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: ipcauth.AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: self,
|
||||
},
|
||||
})
|
||||
return metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
|
||||
// gatewayMetadata reproduces what the daemon receives for a JSON request: the
|
||||
// mux annotates the context from the request's headers, then the interceptor on the
|
||||
// gateway's client connection stamps the caller's identity. The order matters,
|
||||
// since the interceptor must win over anything a header put there.
|
||||
func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
|
||||
t.Helper()
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
|
||||
if !ok {
|
||||
t.Fatal("the interceptor produced no metadata")
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// clientCtx is the connection context jsonConnContext would have produced for an
|
||||
// HTTP client whose identity the gateway could read.
|
||||
func clientCtx(id ipcauth.Identity, known bool) context.Context {
|
||||
return context.WithValue(context.Background(), jsonPeerIdentity{},
|
||||
jsonPeerIdentityValue{id: id, known: known})
|
||||
}
|
||||
|
||||
// An HTTP client must not be able to name its own identity. grpc-gateway turns
|
||||
// Grpc-Metadata-<key> headers into gRPC metadata, so without the header filter and
|
||||
// the interceptor overwriting the reserved keys, this request would authorize as
|
||||
// uid 0.
|
||||
func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
md := gatewayMetadata(t, req, clientCtx(caller, true))
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
t.Errorf("forged header was believed: authorized as %v", id)
|
||||
}
|
||||
if id.UID != caller.UID {
|
||||
t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
|
||||
// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
|
||||
// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
|
||||
// runtime/context.go. That is why the identity is stamped by an interceptor
|
||||
// instead. This is the case that previously reached the gate as the daemon itself.
|
||||
func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header = http.Header{}
|
||||
req.Host = ""
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
ctx := clientCtx(caller, true)
|
||||
|
||||
// Pin the skip path itself: if grpc-gateway ever produced a pair here, this
|
||||
// test would still pass below while no longer covering what it was written for.
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
if md, ok := metadata.FromOutgoingContext(annotated); ok {
|
||||
t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
|
||||
"this test no longer covers the annotator-skip path", md)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, ctx)
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != caller.UID || id.IsPrivileged() {
|
||||
t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
|
||||
// forwards the marker alone. The daemon must then report "unidentified" so the
|
||||
// privileged operations refuse, rather than falling back to the gateway's own
|
||||
// identity.
|
||||
func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no client identity was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// A request that never passed through jsonConnContext (no stashed identity) must
|
||||
// also come out unidentified rather than as the daemon.
|
||||
func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, context.Background())
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no connection context was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end over a real unix socket: the gateway reads the connecting client's
|
||||
// identity from the socket itself, so a client cannot present anything else.
|
||||
func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
type observed struct {
|
||||
md metadata.MD
|
||||
}
|
||||
seen := make(chan observed, 1)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Errorf("annotate: %v", err)
|
||||
return
|
||||
}
|
||||
md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
|
||||
seen <- observed{md: md}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "http.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := srv.Close(); err != nil {
|
||||
t.Logf("close server: %v", err)
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Forge the identity headers on the wire as well.
|
||||
request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
|
||||
"Content-Length: 0\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
// The socket peer is this test process, so that is the identity the
|
||||
// gateway must forward, not the uid 0 the request asked for.
|
||||
if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
|
||||
t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
|
||||
}
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != self.UID {
|
||||
t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the gateway never handled the request")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -126,13 +125,6 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||
daemonAddr = params.DaemonAddr
|
||||
// An install that predates named-pipe support has the loopback TCP
|
||||
// address saved. Callers carry no identity over TCP, so move it to the
|
||||
// pipe instead of restoring a socket the daemon cannot authorize on.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// listenNamedPipe is Windows-only: no other platform serves the daemon on a
|
||||
// named pipe.
|
||||
func listenNamedPipe(string) (net.Listener, string, error) {
|
||||
return nil, "", fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control pipe and reports the path it ended
|
||||
// up on. The security descriptor lets any local caller connect, as a Unix socket
|
||||
// at 0666 does, and the privileged operations are authorized separately from the
|
||||
// caller's token.
|
||||
//
|
||||
// The protected name comes first so that an unprivileged process cannot take the
|
||||
// name before the service does. Creating it requires being an administrator or
|
||||
// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
|
||||
// falls back to the plain name; clients try both and check who serves them.
|
||||
func listenNamedPipe(name string) (net.Listener, string, error) {
|
||||
var errs []error
|
||||
for _, path := range daemonaddr.PipePaths(name) {
|
||||
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("not serving the daemon on %s: %v", path, err)
|
||||
errs = append(errs, fmt.Errorf("%s: %w", path, err))
|
||||
continue
|
||||
}
|
||||
return listener, path, nil
|
||||
}
|
||||
|
||||
return nil, "", errors.Join(errs...)
|
||||
}
|
||||
@@ -26,14 +26,6 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
listener, path, err := listenNamedPipe(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
removeStaleUnixSocket(address)
|
||||
}
|
||||
@@ -49,11 +41,11 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
func parseListenAddress(addr string) (string, string, error) {
|
||||
network, address, ok := strings.Cut(addr, "://")
|
||||
if !ok || network == "" || address == "" {
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "unix", "tcp", "npipe":
|
||||
case "unix", "tcp":
|
||||
return network, address, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||
|
||||
@@ -325,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||
} else {
|
||||
return daemonCallError("call service setConfig method", err)
|
||||
return fmt.Errorf("call service setConfig method: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
}
|
||||
|
||||
if loginErr != nil {
|
||||
return daemonCallError("login failed", loginErr)
|
||||
return fmt.Errorf("login failed: %v", loginErr)
|
||||
}
|
||||
|
||||
if loginResp.NeedsSSOLogin {
|
||||
@@ -392,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
}); err != nil {
|
||||
return daemonCallError("call service up method", err)
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package daemonaddr
|
||||
|
||||
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
|
||||
// user. That is what makes an unprivileged daemon authorize this process for the
|
||||
// changes it otherwise restricts to root or an administrator, so a client can tell
|
||||
// up front whether those controls are usable instead of letting a save fail.
|
||||
//
|
||||
// It is answered from the ownership of the socket or pipe the daemon created, so it
|
||||
// costs no round trip and needs no cooperation from the daemon. Ownership that
|
||||
// cannot be read is reported as false, including for a TCP address, so a caller
|
||||
// reading this as "the daemon would allow it" fails closed. The daemon remains the
|
||||
// only thing that authorizes anything: this only decides what a client offers.
|
||||
func DaemonRunsAsSelf(addr string) bool {
|
||||
return daemonRunsAsSelf(addr)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
|
||||
// process's uid. Root is not treated specially here: a root caller is privileged
|
||||
// on its own merits, and a root-owned socket says nothing about the caller.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
path, ok := strings.CutPrefix(addr, "unix://")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Debugf("stat daemon socket %s: %v", path, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only a socket says anything about a daemon. A directory or a leftover
|
||||
// regular file at that path is not one, and reading it as "the daemon runs as
|
||||
// us" would offer controls the daemon then refuses.
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stat.Uid == uint32(os.Getuid())
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A socket this user created means the daemon runs as this user, which is the
|
||||
// rootless case where the daemon delegates its authority to its own identity.
|
||||
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "netbird.sock")
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Logf("close listener: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !DaemonRunsAsSelf("unix://" + path) {
|
||||
t.Error("a socket owned by this user must count as the daemon running as us")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything that is not a readable socket of ours has to answer false, because
|
||||
// the caller reads a true as "the daemon would authorize me".
|
||||
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A socket owned by another user, which is what a root-run daemon looks like
|
||||
// to an unprivileged client. Only assertable when we are not root ourselves.
|
||||
rootOwned := "unix:///var/run/netbird.sock"
|
||||
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
|
||||
if DaemonRunsAsSelf(rootOwned) {
|
||||
t.Error("a socket owned by another user must not count as ours")
|
||||
}
|
||||
}
|
||||
|
||||
for name, addr := range map[string]string{
|
||||
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
|
||||
"tcp address": "tcp://127.0.0.1:41731",
|
||||
"named pipe": "npipe://netbird",
|
||||
"empty": "",
|
||||
"no scheme": filepath.Join(dir, "absent.sock"),
|
||||
"directory": "unix://" + dir,
|
||||
"unknown scheme": "http://localhost:8080",
|
||||
"scheme only": "unix://",
|
||||
"relative socket": "unix://netbird.sock",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if DaemonRunsAsSelf(addr) {
|
||||
t.Errorf("%q must not count as a daemon running as us", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
|
||||
// service account owns its pipe as LocalSystem, and an elevated one as
|
||||
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
name, ok := strings.CutPrefix(addr, pipeScheme)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, path := range PipePaths(name) {
|
||||
// Bounded: this runs on the UI's path for deciding which controls to
|
||||
// offer, so a pipe that does not answer promptly must not stall it. A
|
||||
// timeout leaves the caller unprivileged, which only disables controls.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
||||
conn, err := dialPipe(ctx, path)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
owned := ipcauth.PipeOwnedBySelf(conn)
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
|
||||
// carries the connecting process's token, which loopback TCP does not, so
|
||||
// it is the only Windows transport on which the daemon can tell who is
|
||||
// calling it.
|
||||
WindowsPipeAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsAddr = "tcp://127.0.0.1:41731"
|
||||
|
||||
pipeScheme = "npipe://"
|
||||
|
||||
// protectedPrefix is the NPFS namespace in which only LocalSystem and
|
||||
// members of BUILTIN\Administrators may create a pipe. A daemon running as
|
||||
// the service account creates its pipe there so that an unprivileged process
|
||||
// cannot pre-create the name, which would keep the daemon from starting and
|
||||
// leave callers talking to the squatter. Opening such a pipe needs no
|
||||
// privilege, so unprivileged clients still reach the daemon.
|
||||
protectedPrefix = `ProtectedPrefix\Administrators\`
|
||||
)
|
||||
|
||||
// DialTarget returns the gRPC dial target and transport options for a daemon
|
||||
// address. The npipe scheme needs a context dialer because gRPC has no
|
||||
// named-pipe resolver; unix and tcp are handled by gRPC itself.
|
||||
func DialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
|
||||
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
|
||||
paths := PipePaths(name)
|
||||
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return dialPipePaths(ctx, paths)
|
||||
}))
|
||||
return "passthrough:///netbird-daemon-pipe", opts
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(addr, "tcp://"), opts
|
||||
}
|
||||
|
||||
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
|
||||
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
|
||||
// is.
|
||||
func PipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
// PipePaths returns the paths a daemon control pipe may live at for an npipe
|
||||
// address name, in the order both sides must try them: the protected name first,
|
||||
// then the plain one.
|
||||
//
|
||||
// The daemon serves the first it can create, which is the protected name when it
|
||||
// runs as the service account and the plain one when it runs as an ordinary user,
|
||||
// as it does in netstack mode. Clients therefore have to try both, and because a
|
||||
// client cannot tell from the name alone who created the pipe, the plain name is
|
||||
// only usable once the server's identity has been checked: see
|
||||
// verifyPipeServer.
|
||||
//
|
||||
// A fully qualified path is what the operator asked for and is used as is.
|
||||
func PipePaths(name string) []string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return []string{name}
|
||||
}
|
||||
return []string{PipePath(protectedPrefix + name), PipePath(name)}
|
||||
}
|
||||
|
||||
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
|
||||
// administrator or LocalSystem can create in, which is what lets a client trust
|
||||
// such a pipe from its name alone.
|
||||
func IsProtectedPipePath(path string) bool {
|
||||
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
|
||||
}
|
||||
|
||||
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
|
||||
// pipe, reporting whether it rewrote the address. Existing installs persist the
|
||||
// daemon address, so without this an upgraded daemon would keep listening on
|
||||
// loopback TCP, where callers carry no identity and privileged operations would
|
||||
// have to be refused for everyone. Only the exact legacy default is rewritten:
|
||||
// a deliberately chosen custom address is left alone.
|
||||
func MigrateLegacy(addr string) (string, bool) {
|
||||
return migrateLegacyForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsAddr {
|
||||
return WindowsPipeAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
|
||||
// pipe.
|
||||
func dialPipePaths(context.Context, []string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The protected name must be tried before the plain one on both sides: it is the
|
||||
// one an unprivileged process cannot create, so preferring it is what keeps a
|
||||
// squatter from owning the name the service daemon would otherwise use.
|
||||
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
|
||||
got := PipePaths("netbird")
|
||||
want := []string{
|
||||
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
|
||||
`\\.\pipe\netbird`,
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("PipePaths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who passes a full path chose exactly one pipe, so neither side may
|
||||
// look anywhere else.
|
||||
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
|
||||
path := `\\.\pipe\custom-netbird`
|
||||
got := PipePaths(path)
|
||||
if !slices.Equal(got, []string{path}) {
|
||||
t.Errorf("PipePaths = %q, want just %q", got, path)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// dialPipePaths connects to the first path that answers with a pipe server this
|
||||
// client may trust, and returns the last error when none does.
|
||||
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
conn, err := dialPipe(ctx, path)
|
||||
if err != nil {
|
||||
log.Debugf("dial daemon pipe %s: %v", path, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// A pipe in the protected namespace could only have been created by an
|
||||
// administrator or LocalSystem, so its name is the guarantee. Any other
|
||||
// name has to be checked, because any local user can create one.
|
||||
if !IsProtectedPipePath(path) {
|
||||
if err := ipcauth.PipeServerTrusted(conn); err != nil {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no daemon pipe to connect to")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
|
||||
// cannot read the caller's token at all. Identification lets the daemon read the
|
||||
// caller's SID and groups without granting it the ability to act as the caller.
|
||||
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
|
||||
// default to fall back from.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
return addr
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
|
||||
// listening answers immediately and one that is not fails immediately.
|
||||
const probeTimeout = 300 * time.Millisecond
|
||||
|
||||
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
|
||||
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
|
||||
// a client meeting a daemon that has not restarted since the upgrade can say what
|
||||
// is wrong, but it does not connect there.
|
||||
//
|
||||
// Using that address automatically would be a downgrade the user never asked for:
|
||||
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
|
||||
// transport carries no caller identity, so a client that accepted whatever answered
|
||||
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
|
||||
// operator who needs the legacy address during the upgrade window can still pass
|
||||
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
|
||||
// privileged operations.
|
||||
//
|
||||
// Only the pipe address is resolved. A custom address is left alone, though passing
|
||||
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
|
||||
// here, so it is treated the same way.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
if addr != WindowsPipeAddr {
|
||||
return addr
|
||||
}
|
||||
|
||||
for _, path := range PipePaths("netbird") {
|
||||
if pipeAvailable(path) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
if tcpAvailable(legacyWindowsAddr) {
|
||||
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
|
||||
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
|
||||
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
|
||||
"explicitly if you accept that",
|
||||
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func pipeAvailable(path string) bool {
|
||||
timeout := probeTimeout
|
||||
conn, err := winio.DialPipe(path, &timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon pipe probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tcpAvailable(addr string) bool {
|
||||
host := addr
|
||||
if _, after, ok := strings.Cut(addr, "://"); ok {
|
||||
host = after
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, probeTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon TCP probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -51,5 +51,7 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
n.listener.OnNetworkChanged("")
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// errUnsupported is returned on platforms with no local peer-identity
|
||||
// primitive, so consumers fail closed instead of guessing an identity.
|
||||
var errUnsupported = errors.New("peer identity is not available on this platform")
|
||||
|
||||
// NewTransportCredentials returns nil: without a peer-identity primitive the
|
||||
// daemon cannot authenticate local callers, and the caller must treat that as
|
||||
// "authorization cannot be enforced".
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PeerIdentity always fails on this platform.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
|
||||
// ConnIdentity always fails on this platform.
|
||||
func ConnIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that expose the
|
||||
// caller's kernel-authenticated identity via IdentityFromContext. It returns
|
||||
// nil on platforms that have no peer-identity primitive, which the caller must
|
||||
// treat as "authorization cannot be enforced".
|
||||
//
|
||||
// The handshake exchanges no bytes on the wire, so a client dialing with
|
||||
// insecure credentials interoperates with a server using these. That keeps
|
||||
// older CLI and UI binaries working against an upgraded daemon.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted local IPC
|
||||
// connection. It is shared by the gRPC transport credentials and by the JSON
|
||||
// gateway, which reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return PeerIdentity(conn)
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed when it cannot
|
||||
// be read, so a connection whose caller is unknown never reaches a handler.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
|
||||
|
||||
func (unixCreds) OverrideServerName(string) error { return nil }
|
||||
@@ -1,194 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
|
||||
//
|
||||
// D:P protected DACL, no inheritance
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
|
||||
//
|
||||
// Any local caller may connect, as with a Unix socket at 0666; what a caller may
|
||||
// actually do is decided from its token, not from the DACL. Remote callers are not
|
||||
// a concern here: winio.ListenPipe creates the pipe with
|
||||
// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
|
||||
// before the descriptor is consulted.
|
||||
//
|
||||
// A deny ACE on the NETWORK SID would not add anything and would break callers:
|
||||
// that SID is present in any network-logon token, which includes OpenSSH and WinRM
|
||||
// sessions, so it denies administrators driving the CLI over SSH and denies the
|
||||
// daemon itself when started from such a session.
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token.
|
||||
//
|
||||
// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
|
||||
// to read its token, which is what DialNamedPipe does.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted named-pipe
|
||||
// connection by impersonating the pipe client and reading its token. It is
|
||||
// shared by the gRPC transport credentials and by the JSON gateway, which
|
||||
// reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity and fails closed
|
||||
// when the handle or token cannot be read, so a connection whose caller is
|
||||
// unknown never reaches a handler.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (winpipeCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID, usable group
|
||||
// SIDs, and elevation state by impersonating the pipe client on this thread
|
||||
// and reading the resulting impersonation token.
|
||||
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
|
||||
// Impersonation is per-thread, so the goroutine must stay on this thread
|
||||
// until RevertToSelf, otherwise an unrelated goroutine could inherit the
|
||||
// impersonated context.
|
||||
runtime.LockOSThread()
|
||||
|
||||
// The thread only goes back to the runtime's pool once it is provably no
|
||||
// longer impersonating the client. If the revert fails, leaving it locked
|
||||
// makes Go terminate it when this goroutine exits, which costs one thread
|
||||
// and keeps a thread running as the client from ever being reused.
|
||||
clean := false
|
||||
defer func() {
|
||||
if clean {
|
||||
runtime.UnlockOSThread()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
clean = true
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Surface the revert failure only when nothing else failed: leaving
|
||||
// the thread impersonated is worse than the original error.
|
||||
revErr := windows.RevertToSelf()
|
||||
if revErr != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("revert impersonation: %w", revErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
clean = true
|
||||
}()
|
||||
|
||||
// openAsSelf=true opens the token with the daemon's own process context
|
||||
// rather than the impersonated client's, so the open cannot fail because
|
||||
// the client lacks access to its own token.
|
||||
var token windows.Token
|
||||
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := token.Close(); cerr != nil {
|
||||
log.Debugf("close client token: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return identityFromToken(token)
|
||||
}
|
||||
|
||||
// identityFromToken reads the user SID, usable group SIDs and elevation state
|
||||
// out of a Windows token.
|
||||
func identityFromToken(token windows.Token) (Identity, error) {
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
|
||||
groups, err := tokenGroupSIDs(token)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: user.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tokenGroupSIDs returns the SIDs of the groups the token can actually
|
||||
// exercise. Groups that are disabled or marked deny-only are skipped: a
|
||||
// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
|
||||
// treating that as membership would hand every admin account privilege it
|
||||
// cannot currently use.
|
||||
func tokenGroupSIDs(token windows.Token) ([]string, error) {
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read token groups: %w", err)
|
||||
}
|
||||
|
||||
var sids []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
|
||||
continue
|
||||
}
|
||||
if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
}
|
||||
sids = append(sids, g.Sid.String())
|
||||
}
|
||||
return sids, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Metadata keys the local JSON gateway uses to forward the identity of its own
|
||||
// HTTP client to the daemon. The gateway runs inside the daemon process and
|
||||
// re-dials the daemon over the control socket, so without forwarding every
|
||||
// JSON request would appear to come from the daemon itself.
|
||||
const (
|
||||
// mdFwd marks a request as forwarded by the JSON gateway. It is always
|
||||
// set, even when the gateway could not read its client's identity, so the
|
||||
// daemon can tell "no identity available" apart from "not forwarded".
|
||||
mdFwd = "x-netbird-fwd"
|
||||
mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
|
||||
mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
|
||||
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
|
||||
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
|
||||
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
|
||||
|
||||
// mdFwdProof proves the forwarded identity was stamped by this process. The
|
||||
// gateway runs inside the daemon, so a secret held in memory is available to
|
||||
// the only legitimate producer and to nothing else.
|
||||
mdFwdProof = "x-netbird-fwd-proof"
|
||||
)
|
||||
|
||||
// forwardKeys is every metadata key the gateway sets. An HTTP client must never
|
||||
// be able to supply one itself: see IsReservedForwardKey.
|
||||
var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
|
||||
|
||||
// forwardProof authenticates the gateway's forwarding metadata. It is generated
|
||||
// once per daemon process and never leaves it: it is not written to disk, not
|
||||
// logged, and not sent anywhere except over the daemon's own control socket to
|
||||
// itself.
|
||||
//
|
||||
// Without it, trusting a forwarded identity rests on every layer in front of it
|
||||
// stripping incoming forwarding keys, and on each key's value shape being
|
||||
// distinguishable from an injected one. A single injected group SID or an
|
||||
// injected "elevated" flag has the same shape as a legitimate one, so no
|
||||
// cardinality rule can catch it. Requiring the proof means metadata that did not
|
||||
// come from this process is refused whatever it contains.
|
||||
var forwardProof = mustForwardProof()
|
||||
|
||||
func mustForwardProof() string {
|
||||
var buf [32]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// Continuing would leave the forwarded path authenticated by a
|
||||
// predictable value, which is worse than not starting.
|
||||
panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
|
||||
// gateway's identity forwarding, and therefore must be dropped when it arrives
|
||||
// from outside.
|
||||
//
|
||||
// grpc-gateway maps "Grpc-Metadata-<key>" request headers into gRPC metadata and
|
||||
// joins them ahead of the values its own annotators add. Without dropping these,
|
||||
// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
|
||||
// because the daemon trusts forwarded metadata when the transport peer is the
|
||||
// (privileged) gateway.
|
||||
func IsReservedForwardKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
return slices.Contains(forwardKeys, key)
|
||||
}
|
||||
|
||||
// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
|
||||
// gateway to forward to the daemon. When known is false only the marker is
|
||||
// set, which makes the daemon treat the caller as unidentified rather than as
|
||||
// the daemon itself.
|
||||
func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
|
||||
md := metadata.MD{}
|
||||
md.Set(mdFwd, "1")
|
||||
md.Set(mdFwdProof, forwardProof)
|
||||
if !known {
|
||||
return md
|
||||
}
|
||||
|
||||
if id.IsWindows() {
|
||||
md.Set(mdFwdSID, id.SID)
|
||||
if len(id.Groups) > 0 {
|
||||
md.Set(mdFwdGroup, id.Groups...)
|
||||
}
|
||||
if id.Elevated {
|
||||
md.Set(mdFwdElevated, "1")
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
|
||||
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
|
||||
return md
|
||||
}
|
||||
|
||||
// CallerIdentity returns the identity to authorize a request against. For a
|
||||
// direct connection that is the transport peer's kernel identity. For a
|
||||
// request relayed by the local JSON gateway it is the identity the gateway
|
||||
// forwarded, since the transport peer is then the daemon itself.
|
||||
//
|
||||
// A forwarded identity is only honoured when the transport peer is the daemon's
|
||||
// own identity and the metadata carries this process's forwarding proof, so
|
||||
// forged forwarding metadata gains a caller nothing. A forwarded request that
|
||||
// carries no identity is reported as unidentified, never as the daemon.
|
||||
//
|
||||
// The second return value is false when no identity could be established, and
|
||||
// callers MUST fail closed in that case.
|
||||
func CallerIdentity(ctx context.Context) (Identity, bool) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// A forwarding key that arrives more than once did not come from the gateway
|
||||
// alone, so nothing about the request can be trusted to describe its caller.
|
||||
// Refusing outright matters because the alternative reading, "not forwarded",
|
||||
// would authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if duplicatedForwardKey(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
forwarded := isForwarded(ctx)
|
||||
|
||||
// Our own process on the other end of the socket is the JSON gateway, the only
|
||||
// thing that dials the daemon from inside it. Such a call must carry a
|
||||
// forwarded identity; without one there is no caller to authorize, and
|
||||
// treating it as the daemon would authorize whatever reached the JSON socket.
|
||||
// Only Linux reports the peer PID, so this is a belt on top of the gateway's
|
||||
// interceptor rather than the sole guarantee.
|
||||
if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// Only the gateway's own connection may speak for someone else. Being
|
||||
// privileged is not enough and not the point: the gateway runs inside the
|
||||
// daemon, so it dials as the daemon's identity whatever user that is, which
|
||||
// also covers a rootless container.
|
||||
if !forwarded || !IsDaemonSelf(id) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Speaking for someone else additionally requires the proof only this process
|
||||
// holds. Refusing is the only safe reading: the transport peer here is the
|
||||
// daemon itself, so falling back to it would authorize the request as the
|
||||
// daemon. This is also what makes the forwarded values trustworthy once
|
||||
// accepted, so they need no shape checks of their own.
|
||||
if !authenticForward(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
return forwardedIdentity(ctx)
|
||||
}
|
||||
|
||||
// duplicatedForwardKey reports whether any forwarding key carries more than one
|
||||
// value. The gateway's interceptor sets each key exactly once and replaces what
|
||||
// was already there, so a repeat means a second source supplied it.
|
||||
func duplicatedForwardKey(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, key := range forwardKeys {
|
||||
// Group SIDs are legitimately repeated; the rest identify the caller.
|
||||
if key == mdFwdGroup {
|
||||
continue
|
||||
}
|
||||
if len(md.Get(key)) > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticForward reports whether the request carries this process's forwarding
|
||||
// proof, which only the in-process JSON gateway can supply.
|
||||
func authenticForward(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
got := mdSingle(md, mdFwdProof)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
|
||||
}
|
||||
|
||||
// isForwarded reports whether the request carries the JSON gateway marker.
|
||||
func isForwarded(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return mdSingle(md, mdFwd) != ""
|
||||
}
|
||||
|
||||
// forwardedIdentity decodes the identity the JSON gateway attached.
|
||||
func forwardedIdentity(ctx context.Context) (Identity, bool) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
if sid := mdSingle(md, mdFwdSID); sid != "" {
|
||||
return Identity{
|
||||
SID: sid,
|
||||
// Repeated by design, one value per group, and only reachable once
|
||||
// the forwarding proof has been verified.
|
||||
Groups: md.Get(mdFwdGroup),
|
||||
Elevated: mdSingle(md, mdFwdElevated) == "1",
|
||||
}, true
|
||||
}
|
||||
|
||||
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
|
||||
if err != nil {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
id := Identity{UID: uint32(uid)}
|
||||
if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
|
||||
id.GID = uint32(gid)
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// mdSingle returns the value of a forwarded key only when exactly one was
|
||||
// supplied. The gateway's interceptor sets each key exactly once, so more than one
|
||||
// value means something else also supplied it, and the whole identity is treated as
|
||||
// unknown rather than picking a winner. Defence in depth behind the gateway's
|
||||
// header filter.
|
||||
func mdSingle(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) == 1 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
|
||||
// gateway's call to the daemon, replacing any forwarding keys already present so
|
||||
// values supplied from outside cannot survive alongside it.
|
||||
//
|
||||
// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
|
||||
// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
|
||||
// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
|
||||
// Host header over a unix socket achieves. The daemon would then see an unmarked
|
||||
// call whose transport peer is the daemon's own identity, and authorize it as the
|
||||
// daemon. A client interceptor runs for every RPC regardless of headers.
|
||||
func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
md = metadata.MD{}
|
||||
} else {
|
||||
md = md.Copy()
|
||||
}
|
||||
|
||||
for _, key := range forwardKeys {
|
||||
delete(md, key)
|
||||
}
|
||||
for key, values := range ForwardIdentityMetadata(id, known) {
|
||||
md[key] = values
|
||||
}
|
||||
|
||||
return metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// transportCtx builds a request context as the daemon's transport credentials
|
||||
// would: the identity of whoever opened the socket, plus whatever metadata the
|
||||
// request carried.
|
||||
func transportCtx(id Identity, md metadata.MD) context.Context {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
},
|
||||
})
|
||||
if md != nil {
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
var (
|
||||
root = Identity{UID: 0}
|
||||
unprivUser = Identity{UID: 1000, GID: 1000}
|
||||
)
|
||||
|
||||
// asDaemon pins which identity counts as this process for the duration of a test.
|
||||
// Without it the test binary's own uid decides, which silently changes what
|
||||
// "the gateway" means.
|
||||
func asDaemon(t *testing.T, id Identity) {
|
||||
t.Helper()
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
selfIdentity, selfKnown = id, true
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
}
|
||||
|
||||
func TestCallerIdentity_DirectConnections(t *testing.T) {
|
||||
t.Run("no transport credentials is not an identity", func(t *testing.T) {
|
||||
if _, ok := CallerIdentity(context.Background()); ok {
|
||||
t.Fatal("a caller with no credentials must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a direct caller is its transport identity", func(t *testing.T) {
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, nil))
|
||||
if !ok || id.UID != 1000 {
|
||||
t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// The whole point of honouring forwarded metadata only from a privileged
|
||||
// transport peer: an unprivileged caller can set any metadata it likes on its
|
||||
// own connection to the daemon socket.
|
||||
t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, forged))
|
||||
if !ok {
|
||||
t.Fatal("caller should still be identified, as itself")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("forged metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCallerIdentity_GatewayForwarding(t *testing.T) {
|
||||
t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(unprivUser, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok {
|
||||
t.Fatal("forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(root, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok || !id.IsPrivileged() {
|
||||
t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// A JSON socket the gateway cannot read peer credentials from (a TCP socket,
|
||||
// say) must not make every request look like the daemon itself.
|
||||
t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(Identity{}, false)
|
||||
if _, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatal("a forwarded request with no identity must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
// grpc-gateway turns Grpc-Metadata-<key> headers into gRPC metadata and joins
|
||||
// them ahead of its annotators' values. If an HTTP client's header survived
|
||||
// that, this is the shape the daemon would see: the attacker's uid 0 first,
|
||||
// the real uid second. The gateway filters those headers out, and reading a
|
||||
// duplicated key as unknown makes the daemon safe even if it did not.
|
||||
t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "0") // injected by the HTTP client
|
||||
md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("injected uid was accepted: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a duplicated marker is not believed either", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "1000")
|
||||
// A repeated marker must not be read as "not forwarded": that would
|
||||
// authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("a duplicated marker was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
// The layers in front of this (the gateway's header matcher, and its
|
||||
// interceptor replacing every forwarding key) are what keep outside metadata
|
||||
// from arriving at all. The proof is what the daemon can check for itself, and
|
||||
// it is the only defence that works for a value whose legitimate shape is
|
||||
// indistinguishable from an injected one: a lone group SID, or "elevated".
|
||||
t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
for name, md := range map[string]metadata.MD{
|
||||
"no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
|
||||
"wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
|
||||
"windows identity without a proof": metadata.Pairs(mdFwd, "1",
|
||||
mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// A caller that reaches the gateway cannot see the proof, so it cannot append
|
||||
// a group of its own to a genuine forwarded identity: doing so would have to
|
||||
// go through the interceptor, which replaces the whole set.
|
||||
t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
injected := metadata.MD{}
|
||||
injected.Append(mdFwdGroup, sidAdministrators)
|
||||
|
||||
ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
|
||||
Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
|
||||
out, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("no outgoing metadata")
|
||||
}
|
||||
if groups := out.Get(mdFwdGroup); len(groups) != 0 {
|
||||
t.Fatalf("injected group survived: %v", groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsReservedForwardKey(t *testing.T) {
|
||||
for _, key := range forwardKeys {
|
||||
if !IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must be reserved", key)
|
||||
}
|
||||
}
|
||||
|
||||
// grpc-gateway canonicalises header names, so the check has to be
|
||||
// case-insensitive.
|
||||
if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
|
||||
t.Error("the check must be case-insensitive")
|
||||
}
|
||||
|
||||
for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
|
||||
if IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must not be reserved", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id Identity
|
||||
known bool
|
||||
}{
|
||||
{"known unix identity", unprivUser, true},
|
||||
{"unknown identity", Identity{}, false},
|
||||
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
md := ForwardIdentityMetadata(tc.id, tc.known)
|
||||
if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
|
||||
t.Fatalf("marker = %v, want exactly one \"1\"", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
||||
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
||||
// context, so the daemon can authorize individual RPCs by caller identity.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
|
||||
// named-pipe client token. Platforms without a peer-identity primitive get no
|
||||
// credentials, and every consumer must fail closed when no identity is
|
||||
// available.
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// Well-known Windows SIDs that identify a fully privileged principal.
|
||||
const (
|
||||
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
|
||||
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
|
||||
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
|
||||
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
|
||||
)
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The
|
||||
// zero value is not a valid identity: consumers must only use one obtained
|
||||
// with a true ok/nil error return.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID. Both are
|
||||
// zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// SID is the caller's Windows security identifier, empty on Unix.
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake time. Only groups that are enabled and not
|
||||
// deny-only are captured, so a group listed here is one the caller can
|
||||
// actually exercise. Empty on Unix.
|
||||
Groups []string
|
||||
|
||||
// Elevated reports whether the Windows client token is elevated (running
|
||||
// as administrator, or an administrator with UAC turned off). Always false
|
||||
// on Unix, where privilege is uid 0.
|
||||
Elevated bool
|
||||
|
||||
// PID is the caller's process ID where the platform reports it (Linux's
|
||||
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
|
||||
// process dialling itself, which is what the JSON gateway does, and is never
|
||||
// used to grant anything.
|
||||
PID int32
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
// rather than a Unix uid/gid principal.
|
||||
func (i Identity) IsWindows() bool {
|
||||
return i.SID != ""
|
||||
}
|
||||
|
||||
// IsPrivileged reports whether the caller is the platform's administrative
|
||||
// principal, which is what the daemon requires for changes that cross the
|
||||
// user-to-root boundary.
|
||||
//
|
||||
// On Windows the decision comes from the caller's token rather than from
|
||||
// account names or group RIDs: an elevated token, one of the service accounts
|
||||
// the daemon itself may run as, or a token with BUILTIN\Administrators
|
||||
// enabled. A UAC-filtered administrator has that group marked deny-only, and
|
||||
// deny-only groups are dropped when the identity is captured, so such a
|
||||
// caller is correctly reported as unprivileged. Domain group memberships
|
||||
// (Domain Admins and friends) are deliberately not consulted: they say
|
||||
// nothing about what this token may do on this machine.
|
||||
func (i Identity) IsPrivileged() bool {
|
||||
if !i.IsWindows() {
|
||||
return i.UID == 0
|
||||
}
|
||||
|
||||
if i.Elevated {
|
||||
return true
|
||||
}
|
||||
|
||||
switch i.SID {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService:
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(i.Groups, sidAdministrators)
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs and denial messages.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
||||
}
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
|
||||
// handlers can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
// AuthType identifies the authentication scheme.
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC
|
||||
// transport credentials were negotiated, which happens on a TCP daemon socket
|
||||
// and on platforms without a peer-identity primitive. Callers MUST fail closed
|
||||
// in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
info, ok := p.AuthInfo.(AuthInfo)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
return info.Identity, true
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the
|
||||
// kernel at connect() time and carries the peer's uid and its group list, of
|
||||
// which the first entry is the primary group.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Xucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by
|
||||
// the kernel at connect() time and cannot be changed for the life of the
|
||||
// connection, so they are not spoofable by the caller.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Ucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// PipeServerTrusted reports an error unless the pipe behind conn was created by a
|
||||
// principal this client may hand secrets to. Clients call it for a pipe whose name
|
||||
// carries no guarantee of its own, which is any name outside the
|
||||
// ProtectedPrefix\Administrators namespace: that namespace already restricts
|
||||
// creation to administrators and LocalSystem, while a plain name can be created by
|
||||
// any local user before the daemon gets there.
|
||||
//
|
||||
// The decision is made from the pipe object's owner, not from the serving process,
|
||||
// because a client cannot open a process running as another user at all, and the
|
||||
// legitimate case is precisely an unprivileged client talking to a privileged
|
||||
// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
|
||||
// this client's own user, the last of which is the daemon a user runs themselves
|
||||
// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
|
||||
// key or SSO prompt out of this client.
|
||||
func PipeServerTrusted(conn net.Conn) error {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !trustedPipeOwner(owner) {
|
||||
return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
|
||||
// user, which is how a client recognises a daemon running as itself. Ownership it
|
||||
// cannot read is reported as false.
|
||||
func PipeOwnedBySelf(conn net.Conn) bool {
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
log.Debugf("read daemon pipe owner: %v", err)
|
||||
return false
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
|
||||
// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
|
||||
// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
|
||||
// access is needed.
|
||||
func pipeOwnerSID(handle windows.Handle) (string, error) {
|
||||
sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe security info: %w", err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe owner: %w", err)
|
||||
}
|
||||
return owner.String(), nil
|
||||
}
|
||||
|
||||
// trustedPipeOwner reports whether a pipe's owner is a principal a client may
|
||||
// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
|
||||
// default, an unelevated one's by the user, which is why both forms appear here.
|
||||
func trustedPipeOwner(owner string) bool {
|
||||
switch owner {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
|
||||
return true
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
|
||||
// raises for an operation that requires root/administrator. Clients match on
|
||||
// Reason and Domain rather than on the message text, and render the summary and
|
||||
// command themselves so the user gets guidance instead of a gRPC error dump.
|
||||
const (
|
||||
// ErrorReasonPrivilegeRequired identifies the detail.
|
||||
ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
|
||||
// ErrorDomain scopes the reason to the NetBird daemon.
|
||||
ErrorDomain = "daemon.netbird.io"
|
||||
// ErrorMetaSummary is the one-sentence explanation of what was refused.
|
||||
ErrorMetaSummary = "summary"
|
||||
// ErrorMetaCommand is the command that performs the same operation with the
|
||||
// privileges it needs, ready to copy and run.
|
||||
ErrorMetaCommand = "command"
|
||||
)
|
||||
|
||||
// The identity of the process evaluating callers, captured once because it cannot
|
||||
// change. selfKnown is false when it could not be read, in which case nothing is
|
||||
// ever treated as this process. selfMayDelegate additionally requires this
|
||||
// process to be unprivileged: see IsPrivilegedCaller.
|
||||
var (
|
||||
selfIdentity Identity
|
||||
selfKnown bool
|
||||
selfMayDelegate bool
|
||||
// selfPID is this process's PID, used to recognise the daemon dialling itself.
|
||||
selfPID = os.Getpid()
|
||||
)
|
||||
|
||||
func init() {
|
||||
id, err := CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
selfIdentity, selfKnown = id, true
|
||||
// Only an unprivileged daemon delegates its authority to its own identity.
|
||||
// When it is root or LocalSystem, sharing its identity does not mean sharing
|
||||
// its power: on Windows a filtered and a full token carry the same SID, so
|
||||
// matching there would let a non-elevated shell of an administrator account
|
||||
// act as an administrator, which is the boundary the token check exists to
|
||||
// keep.
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
}
|
||||
|
||||
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
|
||||
// runs inside the daemon and re-dials it locally, so this is what distinguishes
|
||||
// the gateway from any other caller, whatever user the daemon runs as.
|
||||
func IsDaemonSelf(id Identity) bool {
|
||||
if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() {
|
||||
return false
|
||||
}
|
||||
if id.IsWindows() {
|
||||
return id.SID != "" && id.SID == selfIdentity.SID
|
||||
}
|
||||
return id.UID == selfIdentity.UID
|
||||
}
|
||||
|
||||
// IsPrivilegedCaller reports whether an identity may make the changes the daemon
|
||||
// restricts to the platform administrator. This is the daemon's own rule and
|
||||
// cannot be evaluated by a client, which does not know what the daemon runs as.
|
||||
//
|
||||
// Beyond root/administrator it accepts a caller running as the daemon's own
|
||||
// identity when the daemon is itself unprivileged. That keeps a rootless container
|
||||
// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
|
||||
// which needs no administrator rights. In those setups a caller sharing the
|
||||
// daemon's identity can already rewrite the config files it reads and replace the
|
||||
// binary it runs, so refusing it a config change would protect nothing; and an
|
||||
// unprivileged daemon cannot hand out a root shell in the first place.
|
||||
func IsPrivilegedCaller(id Identity) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
return selfMayDelegate && IsDaemonSelf(id)
|
||||
}
|
||||
|
||||
// SelfDelegatesTo returns the identity this process delegates its authority to,
|
||||
// and whether it delegates at all. Only an unprivileged daemon does: see
|
||||
// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
|
||||
// operation, because on such a host root is neither required nor necessarily
|
||||
// available.
|
||||
func SelfDelegatesTo() (Identity, bool) {
|
||||
if !selfKnown || !selfMayDelegate {
|
||||
return Identity{}, false
|
||||
}
|
||||
return selfIdentity, true
|
||||
}
|
||||
|
||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||
// in messages shown to the user.
|
||||
func PrivilegedActor() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "administrator privileges"
|
||||
}
|
||||
return "root"
|
||||
}
|
||||
|
||||
// ElevatedCommand renders a command so that running it grants the privileges the
|
||||
// operation needs. Windows has no in-line equivalent of sudo, so the command is
|
||||
// returned unchanged and the user is expected to run it from an elevated
|
||||
// terminal.
|
||||
func ElevatedCommand(command string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return command
|
||||
}
|
||||
return "sudo " + command
|
||||
}
|
||||
|
||||
// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
|
||||
// `down`. The down is what makes the command work on a connected client: `netbird
|
||||
// up` prints "Already connected" and returns without applying any config flag, so
|
||||
// on its own the command would appear to do nothing. It is a no-op, exit 0, when
|
||||
// the client is not connected.
|
||||
//
|
||||
// ";" rather than "&&" so the line can be pasted into any of the shells a user
|
||||
// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
|
||||
// as a syntax error.
|
||||
func UpCommand(flags string) string {
|
||||
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The self rule is the one place privilege is granted to something other than the
|
||||
// platform administrator, so its two guards matter: it must apply only when the
|
||||
// daemon is itself unprivileged, and only to a caller with the daemon's identity.
|
||||
func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
// self stands in for the process the daemon runs as.
|
||||
self Identity
|
||||
selfKnown bool
|
||||
caller Identity
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "root is privileged whatever the daemon runs as",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged daemon delegates to its own user (rootless container)",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1000},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged daemon delegates to nobody else",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1001},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// The daemon is root on a normal install, so sharing its identity is
|
||||
// already covered by being root; nothing else may match.
|
||||
name: "a root daemon delegates to nobody",
|
||||
self: Identity{UID: 0},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1000},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Windows netstack mode: the daemon needs no administrator rights.
|
||||
name: "an unprivileged windows daemon delegates to its own SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged windows daemon delegates to no other SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1002"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// The UAC boundary: a filtered and a full token of the same account
|
||||
// carry the same SID but not the same power, so an elevated daemon must
|
||||
// never delegate to its own SID.
|
||||
name: "an elevated windows daemon does not delegate to its own SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-500"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "LocalSystem is privileged on its own merits, not by delegation",
|
||||
self: Identity{SID: sidLocalSystem},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: sidLocalSystem},
|
||||
want: true, // LocalSystem is privileged on its own merits
|
||||
},
|
||||
{
|
||||
name: "identities of different kinds never match",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "an unknown self identity delegates to nobody",
|
||||
self: Identity{},
|
||||
selfKnown: false,
|
||||
caller: Identity{UID: 1000},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
|
||||
selfIdentity, selfKnown = tt.self, tt.selfKnown
|
||||
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
|
||||
|
||||
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
|
||||
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
|
||||
tt.caller, tt.self, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The real process must never accidentally delegate: a test binary running as a
|
||||
// normal user is unprivileged, so it may match itself, but nothing else.
|
||||
func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
|
||||
id, err := CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
|
||||
// This process is always allowed to act as itself: either it is privileged, or
|
||||
// it is unprivileged and therefore delegates to its own identity.
|
||||
if !IsPrivilegedCaller(id) {
|
||||
t.Errorf("this process %v was refused its own identity", id)
|
||||
}
|
||||
|
||||
// A caller that is neither root nor this process must be refused, whatever
|
||||
// this process happens to be.
|
||||
other := Identity{UID: id.UID + 1}
|
||||
if id.IsWindows() {
|
||||
other = Identity{SID: id.SID + "9"}
|
||||
}
|
||||
if IsPrivilegedCaller(other) {
|
||||
t.Errorf("an unrelated identity %v was treated as privileged", other)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import "os"
|
||||
|
||||
// CurrentProcessIdentity returns this process's identity as the daemon would
|
||||
// see it if this process connected to the local IPC. It lets a client (the UI)
|
||||
// decide up front whether a privileged operation can succeed, without a
|
||||
// round-trip and without duplicating the rules: the answer comes from the same
|
||||
// Identity.IsPrivileged the daemon applies.
|
||||
func CurrentProcessIdentity() (Identity, error) {
|
||||
return Identity{
|
||||
UID: uint32(os.Geteuid()),
|
||||
GID: uint32(os.Getegid()),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// CurrentProcessIdentity returns this process's identity as the daemon would see
|
||||
// it if this process connected to the local IPC. It lets a client (the UI)
|
||||
// decide up front whether a privileged operation can succeed, without a
|
||||
// round-trip and without duplicating the rules: the answer comes from the same
|
||||
// Identity.IsPrivileged the daemon applies to the token it reads off the pipe.
|
||||
func CurrentProcessIdentity() (Identity, error) {
|
||||
// A pseudo-token, so it must not be closed.
|
||||
token := windows.GetCurrentProcessToken()
|
||||
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
|
||||
groups, err := tokenGroupSIDs(token)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: user.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
@@ -746,13 +746,6 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
// appended for https or ":80" for http. The serviceName parameter is
|
||||
// used to contextualise error messages. On success returns the parsed
|
||||
// *url.URL; on failure returns a non-nil error.
|
||||
// ParseServiceURL normalises a service URL exactly as the config layer does when
|
||||
// it stores one, so callers comparing a requested URL against a stored one do not
|
||||
// have to reimplement the scheme validation and default-port handling.
|
||||
func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
return parseURL(serviceName, serviceURL)
|
||||
}
|
||||
|
||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"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/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A refused login must not leave the profile switched. Login can both switch
|
||||
// profiles and carry the guarded config fields, so the gate has to run before the
|
||||
// switch: otherwise a caller whose change is refused still gets the side effect of
|
||||
// activating whichever profile the request named.
|
||||
func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
|
||||
s, _, activeProfile, username, _ := setupServerWithProfile(t)
|
||||
|
||||
// Login reads process state off the daemon's root context.
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
// A second profile that runs the SSH server, which is what makes repointing
|
||||
// its management binding a privileged change.
|
||||
target := "ssh-enabled"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile")
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(activeProfile), active.ID,
|
||||
"the refused login switched the active profile anyway")
|
||||
}
|
||||
|
||||
// A caller whose change becomes privileged only after its first check must be
|
||||
// refused without having cancelled a login or switched profiles: the first check is
|
||||
// unsynchronized, so the SSH server can be enabled by a concurrent privileged
|
||||
// request in between, and the authoritative check happens before any side effect.
|
||||
func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) {
|
||||
s, _, activeProfile, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
// The target profile has SSH off, so the first check lets the request through.
|
||||
target := "ssh-later"
|
||||
targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json")
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: targetPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled := false
|
||||
s.actCancel = func() { cancelled = true }
|
||||
|
||||
// Stand in for a privileged SetConfig that enables the SSH server between the
|
||||
// two checks, which is the interleaving the lock has to make safe.
|
||||
afterLoginPreCheck = func() {
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: targetPath,
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() { afterLoginPreCheck = nil })
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
require.False(t, cancelled, "the refused login cancelled the login already in progress")
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway")
|
||||
|
||||
stored, err := profilemanager.ReadConfig(targetPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL")
|
||||
}
|
||||
|
||||
// Login cancels whatever login is already in progress before starting its own. A
|
||||
// refused caller must not get that far, otherwise anyone able to reach the socket
|
||||
// can abort someone else's login by sending a request that is denied.
|
||||
func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
target := "ssh-enabled"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled := false
|
||||
s.actCancel = func() { cancelled = true }
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
require.False(t, cancelled, "the refused login cancelled the login already in progress")
|
||||
}
|
||||
@@ -82,12 +82,6 @@ type Server struct {
|
||||
// extend flow or vice versa.
|
||||
extendAuthSessionFlow *auth.PendingFlow
|
||||
|
||||
// guardedConfigMu serializes a privilege check against the write it
|
||||
// authorizes. Without it the two are separate steps over the same file, and a
|
||||
// change that was allowed because the profile had the SSH server disabled
|
||||
// could land after a concurrent privileged request enabled it.
|
||||
guardedConfigMu sync.Mutex
|
||||
|
||||
mutex sync.Mutex
|
||||
config *profilemanager.Config
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
@@ -417,20 +411,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Privilege gate: refuse the parts of the request that would let a local
|
||||
// user turn the root daemon into a root shell. Held across the write so the
|
||||
// config cannot gain the SSH server between the decision and the update.
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := s.setConfigInputFromRequest(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -557,23 +537,22 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(callerCtx)
|
||||
|
||||
md, ok := metadata.FromIncomingContext(callerCtx)
|
||||
if ok {
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
|
||||
// Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
|
||||
// the same fields. It runs before anything here changes daemon state, so a
|
||||
// refused login neither switches the profile nor cancels a login already in
|
||||
// progress, and it reads the profile the request targets, which is the one the
|
||||
// switch below would activate.
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
||||
return nil, err
|
||||
s.actCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
@@ -584,16 +563,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
// The RPC boundary is where this gets recorded: nothing logs handler
|
||||
// errors for us, and a caller that retries would otherwise leave no
|
||||
// trace in the daemon log. A refusal is skipped because the gate has
|
||||
// already logged the decision, with the caller's identity.
|
||||
if gstatus.Code(err) != codes.PermissionDenied {
|
||||
log.Errorf("failed to prepare login: %v", err)
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
||||
@@ -607,6 +593,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
log.Errorf("failed to persist login overrides: %v", err)
|
||||
return nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
config, _, err := s.getConfig(activeProf)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile config: %v", err)
|
||||
@@ -989,63 +980,6 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
|
||||
}
|
||||
}
|
||||
|
||||
// storedProfileConfig loads the on-disk config of the profile a request
|
||||
// targets, so a privileged-change decision can be made against the values the
|
||||
// profile currently holds. A profile that has no config file yet yields nil,
|
||||
// which every caller must read as "nothing enabled yet".
|
||||
func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) {
|
||||
resolved, err := s.resolveProfileHandle(handle, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := resolved.Path
|
||||
if path == "" {
|
||||
path = profilemanager.DefaultConfigPath
|
||||
}
|
||||
|
||||
return s.storedConfigAtPath(path)
|
||||
}
|
||||
|
||||
// storedLoginConfig loads the on-disk config of the profile a login request
|
||||
// targets: the one it names, or the active one when it names none. Used to decide
|
||||
// a privileged change before the request is allowed to switch profiles.
|
||||
func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) {
|
||||
if msg.ProfileName == nil {
|
||||
cfgPath, err := activeProf.FilePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active profile file path: %w", err)
|
||||
}
|
||||
return s.storedConfigAtPath(cfgPath)
|
||||
}
|
||||
|
||||
// Mirrors switchProfileIfNeeded: the default profile resolves without a
|
||||
// username, so this reads the same profile the switch would activate.
|
||||
handle := *msg.ProfileName
|
||||
username := ""
|
||||
if handle != profilemanager.DefaultProfileName {
|
||||
username = msg.GetUsername()
|
||||
}
|
||||
return s.storedProfileConfig(handle, username)
|
||||
}
|
||||
|
||||
// storedConfigAtPath reads a profile config file, yielding nil when it does not
|
||||
// exist yet.
|
||||
func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("stat profile config: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.GetConfig(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveProfileHandle resolves a wire-level profile handle (display
|
||||
// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
|
||||
// status errors so handlers can return them directly.
|
||||
@@ -1263,12 +1197,6 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
|
||||
// A refused deregistration is already a status error carrying the reason
|
||||
// and the command to run; rewrapping it as Internal would flatten both
|
||||
// into a gRPC dump for the user.
|
||||
if _, isStatus := gstatus.FromError(err); isStatus {
|
||||
return nil, err
|
||||
}
|
||||
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
|
||||
}
|
||||
|
||||
@@ -1390,13 +1318,6 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error {
|
||||
// Privilege gate: deregistering frees this machine's key to be registered
|
||||
// against another management server, which is only restricted while the SSH
|
||||
// server makes that a privilege handover.
|
||||
if err := requirePrivilegeForDeregistration(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := wgtypes.ParseKey(config.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse private key: %w", err)
|
||||
@@ -2142,10 +2063,7 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
// Deregistration is best-effort here: the local profile is removed
|
||||
// either way, so an unprivileged caller leaves the peer registered on
|
||||
// the management server rather than being blocked from removing it.
|
||||
log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err)
|
||||
log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
|
||||
}
|
||||
|
||||
if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
|
||||
@@ -2442,69 +2360,6 @@ func sendTerminalNotification() error {
|
||||
|
||||
// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the
|
||||
// active profile config so that subsequent reads pick them up. Empty/nil values are ignored.
|
||||
// afterLoginPreCheck is a seam for tests to run a concurrent config change
|
||||
// between Login's first privilege check and the authoritative one.
|
||||
var afterLoginPreCheck func()
|
||||
|
||||
// authorizeAndPrepareLogin makes the authoritative privilege decision for a login
|
||||
// and, when it passes, carries out every state change that decision authorizes:
|
||||
// cancelling an login already in progress, switching to the requested profile, and
|
||||
// persisting the config overrides the request carries.
|
||||
//
|
||||
// All of it happens under guardedConfigMu, which SetConfig also holds across its
|
||||
// own check and write. Login's earlier check refuses the ordinary case before any
|
||||
// of this is reached; this one exists because that check is not synchronized
|
||||
// against a concurrent privileged request that enables the SSH server, and a
|
||||
// caller refused here must not have cancelled or switched anything either.
|
||||
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
|
||||
if afterLoginPreCheck != nil {
|
||||
afterLoginPreCheck()
|
||||
}
|
||||
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(callerCtx)
|
||||
if md, ok := metadata.FromIncomingContext(callerCtx); ok {
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
s.actCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
return nil, nil, fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("active profile state: %w", err)
|
||||
}
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
return ctx, activeProf, nil
|
||||
}
|
||||
|
||||
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
|
||||
if preSharedKey != nil && *preSharedKey == "" {
|
||||
preSharedKey = nil
|
||||
|
||||
@@ -66,11 +66,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
|
||||
Username: currUser.Username,
|
||||
}))
|
||||
|
||||
// The privileged-change gate reads the caller's kernel identity from the
|
||||
// context, which a real caller gets from the daemon's transport credentials.
|
||||
// This test drives the handler directly, so it stands in for a root caller;
|
||||
// without an identity the gate would (correctly) refuse the SSH fields.
|
||||
ctx = privilegedTestCtx()
|
||||
ctx = context.Background()
|
||||
s = New(ctx, "console", "", false, false, false, false)
|
||||
return s, ctx, profName, currUser.Username, cfgPath
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -51,11 +52,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The privileged-change gate reads the caller's kernel identity from the
|
||||
// context, which a real caller gets from the daemon's transport credentials.
|
||||
// This test drives the handler directly, so it stands in for a root caller;
|
||||
// without an identity the gate would (correctly) refuse the SSH fields.
|
||||
ctx := privilegedTestCtx()
|
||||
ctx := context.Background()
|
||||
s := New(ctx, "console", "", false, false, false, false)
|
||||
|
||||
rosenpassEnabled := true
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// The daemon runs as root/LocalSystem, so a handful of config changes cross the
|
||||
// user-to-root boundary and are restricted to privileged callers:
|
||||
//
|
||||
// - Enabling SSH root login, or disabling SSH authentication, turns the
|
||||
// daemon's SSH server into a root (or unauthenticated) shell.
|
||||
// - Enabling the SSH server at all is what makes the above reachable, and a
|
||||
// profile the caller owns is not a privilege they hold.
|
||||
// - While the SSH server is enabled, repointing the profile at another
|
||||
// management identity hands SSH authorization decisions, including which
|
||||
// keys and users are accepted, to whoever controls that identity. Changing
|
||||
// the management URL and deregistering the peer are both ways to do that.
|
||||
//
|
||||
// Everything else stays unauthenticated, so this is not an authorization model:
|
||||
// it only refuses the changes that would let a local user become root. A caller
|
||||
// whose identity cannot be established is refused as well.
|
||||
|
||||
// privilegedConfigChange is the subset of a config request that crosses the
|
||||
// user-to-root boundary. Fields are nil or empty when the request leaves them
|
||||
// untouched.
|
||||
type privilegedConfigChange struct {
|
||||
managementURL string
|
||||
serverSSHAllowed *bool
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
}
|
||||
|
||||
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// requirePrivilegeForConfigChange refuses the privileged parts of a config
|
||||
// change when the caller is not root/administrator. stored is the profile's
|
||||
// current config, or nil when it has none yet.
|
||||
//
|
||||
// Each check compares against the stored value so that a request restating a
|
||||
// value it does not change is never refused: a UI that submits the whole
|
||||
// settings form must not start failing once an administrator has enabled SSH.
|
||||
func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) {
|
||||
return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root"))
|
||||
}
|
||||
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) {
|
||||
return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth"))
|
||||
}
|
||||
|
||||
if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) {
|
||||
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
|
||||
}
|
||||
|
||||
// Only guard the management binding while the SSH server is enabled: that is
|
||||
// when the management identity decides who may open a shell here.
|
||||
if !sshServerEnabled(stored) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
|
||||
return denyPrivileged(ctx,
|
||||
"changing the management URL while the NetBird SSH server is enabled",
|
||||
ipcauth.UpCommand("-m "+change.managementURL))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePrivilegeForDeregistration refuses to deregister the peer from the
|
||||
// management server when the caller is not privileged and the profile has the
|
||||
// SSH server enabled. Deregistering frees the peer's key to be registered
|
||||
// against another management identity, which is the same handover the
|
||||
// management URL check refuses.
|
||||
//
|
||||
// Callers that treat deregistration as best-effort (profile removal) continue
|
||||
// without it; callers that were asked to deregister surface the error.
|
||||
func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
|
||||
if !sshServerEnabled(cfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return denyPrivileged(ctx,
|
||||
"deregistering this peer while the NetBird SSH server is enabled",
|
||||
ipcauth.ElevatedCommand("netbird logout"))
|
||||
}
|
||||
|
||||
// denyPrivileged returns nil when the caller is privileged, and otherwise a
|
||||
// PermissionDenied whose message names the action and the command that performs
|
||||
// it with the privileges it needs. The same summary and command ride along as an
|
||||
// ErrorInfo detail so the CLI and the UI can present them without parsing text.
|
||||
//
|
||||
// action reads as the subject of a sentence ("enabling SSH root login"), and
|
||||
// command is the equivalent command, already elevated for the platform.
|
||||
func denyPrivileged(ctx context.Context, action, command string) error {
|
||||
id, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action)
|
||||
return privilegeError(unidentifiedSummary(action), reinstallCommand())
|
||||
}
|
||||
|
||||
if ipcauth.IsPrivilegedCaller(id) {
|
||||
log.Infof("allowing %s for privileged caller %s", action, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("denying %s for unprivileged caller %s", action, id)
|
||||
actor, command := requiredActor(command)
|
||||
return privilegeError(privilegeSummary(action, actor), command)
|
||||
}
|
||||
|
||||
// requiredActor names who may perform the operation and adjusts the command to
|
||||
// match. A daemon that is not itself privileged delegates to its own identity, so
|
||||
// telling that host's user to become root is wrong twice over: root is not what the
|
||||
// daemon checks for, and a rootless container has neither root nor sudo.
|
||||
func requiredActor(command string) (string, string) {
|
||||
self, delegates := ipcauth.SelfDelegatesTo()
|
||||
if !delegates {
|
||||
return ipcauth.PrivilegedActor(), command
|
||||
}
|
||||
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
|
||||
}
|
||||
|
||||
// privilegeError builds the PermissionDenied carrying summary and command.
|
||||
func privilegeError(summary, command string) error {
|
||||
st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command))
|
||||
|
||||
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: summary,
|
||||
ipcauth.ErrorMetaCommand: command,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("attach privilege error detail: %v", err)
|
||||
return st.Err()
|
||||
}
|
||||
return detailed.Err()
|
||||
}
|
||||
|
||||
// privilegeSummary states what is refused and what it needs, in one sentence
|
||||
// that reads the same in a dialog and in a terminal.
|
||||
func privilegeSummary(action, actor string) string {
|
||||
return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
|
||||
}
|
||||
|
||||
// unidentifiedSummary covers a control channel that carries no caller identity.
|
||||
// Elevating does not help there, so it points at the daemon's socket instead.
|
||||
func unidentifiedSummary(action string) string {
|
||||
return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+
|
||||
"Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor())
|
||||
}
|
||||
|
||||
// reinstallCommand is the command that moves the daemon onto a socket whose
|
||||
// callers can be identified.
|
||||
func reinstallCommand() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr)
|
||||
}
|
||||
return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// enables reports whether requested turns a flag on that is currently off. A
|
||||
// request that restates the stored value, or turns the flag off, is not a
|
||||
// privileged change.
|
||||
func enables(stored, requested *bool) bool {
|
||||
if requested == nil || !*requested {
|
||||
return false
|
||||
}
|
||||
return stored == nil || !*stored
|
||||
}
|
||||
|
||||
// storedFlag reads a flag from the stored config, tolerating a config that does
|
||||
// not exist yet.
|
||||
func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return get(cfg)
|
||||
}
|
||||
|
||||
// sshServerEnabled reports whether the profile currently runs the SSH server.
|
||||
//
|
||||
// A nil flag means ON, matching what the engine does with the same config
|
||||
// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written
|
||||
// before the flag existed). Reading it as OFF here would open the management-URL
|
||||
// and deregistration guards on exactly those legacy hosts, whose SSH server is
|
||||
// running. Configs loaded through profilemanager have already been materialised by
|
||||
// apply(), so this is the same answer by a route that does not depend on that.
|
||||
func sshServerEnabled(cfg *profilemanager.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed)
|
||||
}
|
||||
|
||||
// sshServerCurrentlyAllowed is the value an enable request is compared against. It
|
||||
// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy
|
||||
// config is correctly seen as no change.
|
||||
func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
|
||||
enabled := sshServerEnabled(cfg)
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return &enabled
|
||||
}
|
||||
|
||||
// sameManagementURL reports whether requested addresses the same management
|
||||
// server as stored, comparing scheme, host and effective port so that an
|
||||
// equivalent spelling ("https://api.netbird.io" for a stored
|
||||
// "https://api.netbird.io:443") is not treated as a change. It fails closed:
|
||||
// anything unparseable counts as a change and therefore needs privilege.
|
||||
func sameManagementURL(stored *url.URL, requested string) bool {
|
||||
if stored == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalise the requested URL through the config layer's own parser, so the
|
||||
// comparison cannot drift from how the value would actually be stored.
|
||||
parsed, err := profilemanager.ParseServiceURL("Management URL", requested)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored.Scheme == parsed.Scheme &&
|
||||
stored.Hostname() == parsed.Hostname() &&
|
||||
effectivePort(stored) == effectivePort(parsed)
|
||||
}
|
||||
|
||||
func effectivePort(u *url.URL) string {
|
||||
if port := u.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return "443"
|
||||
case "http":
|
||||
return "80"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ctxWithIdentity builds a request context carrying the identity the transport
|
||||
// credentials would have attached.
|
||||
func ctxWithIdentity(id ipcauth.Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: ipcauth.AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// unprivUID is deliberately not this process's own uid. An unprivileged daemon
|
||||
// treats a caller sharing its identity as privileged (rootless containers), and
|
||||
// the test binary would otherwise stand in for both the daemon and the caller.
|
||||
// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is
|
||||
// unused.
|
||||
var unprivUID = uint32(os.Geteuid() + 1)
|
||||
|
||||
// The fabricated identities have to be shaped like the platform's: a uid says
|
||||
// nothing on Windows, and a zero uid there would read as root and be privileged.
|
||||
func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) }
|
||||
func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) }
|
||||
|
||||
func privilegedIdentity() ipcauth.Identity {
|
||||
if runtime.GOOS == "windows" {
|
||||
// LocalSystem, which is what the Windows service account is.
|
||||
return ipcauth.Identity{SID: "S-1-5-18"}
|
||||
}
|
||||
return ipcauth.Identity{UID: 0}
|
||||
}
|
||||
|
||||
func unprivilegedIdentity() ipcauth.Identity {
|
||||
if runtime.GOOS == "windows" {
|
||||
// A plain user SID: no groups, so no BUILTIN\Administrators, and not
|
||||
// elevated.
|
||||
return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"}
|
||||
}
|
||||
return ipcauth.Identity{UID: unprivUID, GID: unprivUID}
|
||||
}
|
||||
func noIdentityCtx() context.Context { return context.Background() }
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func assertDenied(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected the change to be refused, got nil")
|
||||
}
|
||||
st := gstatus.Convert(err)
|
||||
if st.Code() != codes.PermissionDenied {
|
||||
t.Fatalf("code = %v, want PermissionDenied", st.Code())
|
||||
}
|
||||
// The refusal must be machine-readable: the CLI and the UI render the
|
||||
// summary and command from the detail rather than parsing the message.
|
||||
var info *errdetails.ErrorInfo
|
||||
for _, d := range st.Details() {
|
||||
if got, ok := d.(*errdetails.ErrorInfo); ok {
|
||||
info = got
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
t.Fatal("refusal carries no ErrorInfo detail")
|
||||
}
|
||||
if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain {
|
||||
t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired)
|
||||
}
|
||||
if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" {
|
||||
t.Error("detail carries no summary")
|
||||
}
|
||||
if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" {
|
||||
t.Error("detail carries no command")
|
||||
}
|
||||
}
|
||||
|
||||
func assertAllowed(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("expected the change to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stored *profilemanager.Config
|
||||
change privilegedConfigChange
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "enabling the ssh server unprivileged is refused",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "enabling the ssh server as root is allowed",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "restating an already enabled ssh server is not a change",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
},
|
||||
{
|
||||
name: "turning the ssh server off is not guarded",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "a profile with no config yet counts as off, so enabling is refused",
|
||||
stored: nil,
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "enabling ssh root login unprivileged is refused",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "restating ssh root login is not a change",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
|
||||
},
|
||||
{
|
||||
name: "turning ssh root login off is not guarded",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "disabling ssh authentication unprivileged is refused",
|
||||
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)},
|
||||
change: privilegedConfigChange{disableSSHAuth: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "re-enabling ssh authentication is not guarded",
|
||||
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
|
||||
change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "a request that touches none of the guarded fields is allowed",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
|
||||
sshOn := func(raw string) *profilemanager.Config {
|
||||
return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}
|
||||
}
|
||||
sshOff := func(raw string) *profilemanager.Config {
|
||||
return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stored *profilemanager.Config
|
||||
requested string
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "moving the binding while ssh is enabled is refused",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://attacker.example.com:443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "moving the binding as root is allowed",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://selfhosted.example.com:443",
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "the same url restated is not a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io:443",
|
||||
},
|
||||
{
|
||||
name: "an equivalent spelling of the same url is not a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io",
|
||||
},
|
||||
{
|
||||
name: "an equivalent spelling with an explicit http port is not a change",
|
||||
stored: sshOn("http://mgmt.internal:80"),
|
||||
requested: "http://mgmt.internal",
|
||||
},
|
||||
{
|
||||
name: "a different port on the same host is a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io:8443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "a different scheme on the same host is a change",
|
||||
stored: sshOn("https://mgmt.internal:443"),
|
||||
requested: "http://mgmt.internal:443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "with ssh disabled the binding is not guarded at all",
|
||||
stored: sshOff("https://api.netbird.io:443"),
|
||||
requested: "https://attacker.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "an unparseable url fails closed",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "ht tp://%zz",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "an empty url leaves the binding alone",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested})
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller the daemon cannot identify must be refused, not trusted: that is the
|
||||
// state on a TCP daemon socket, where no peer credentials exist.
|
||||
func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) {
|
||||
err := requirePrivilegeForConfigChange(noIdentityCtx(),
|
||||
&profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
privilegedConfigChange{serverSSHAllowed: boolPtr(true)})
|
||||
assertDenied(t, err)
|
||||
|
||||
// The guidance must point at the socket rather than at sudo, since elevating
|
||||
// would not help.
|
||||
st := gstatus.Convert(err)
|
||||
if !strings.Contains(st.Message(), "service install") {
|
||||
t.Errorf("message %q does not tell the operator how to fix the socket", st.Message())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForDeregistration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *profilemanager.Config
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "deregistering while ssh is enabled is refused",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "deregistering while ssh is enabled is allowed for root",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "deregistering with ssh disabled is not guarded",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "deregistering a profile with no config is not guarded",
|
||||
cfg: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForDeregistration(ctx, tt.cfg)
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// privilegedTestCtx is the context a handler-level test should use when it is
|
||||
// standing in for a root/administrator caller. Tests that drive the handlers
|
||||
// directly have no transport credentials, and the privileged-change gate refuses
|
||||
// a caller it cannot identify.
|
||||
func privilegedTestCtx() context.Context { return rootCtx() }
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -30,7 +32,7 @@ const (
|
||||
// DefaultDaemonAddr is the default address for the NetBird daemon
|
||||
DefaultDaemonAddr = "unix:///var/run/netbird.sock"
|
||||
// DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows
|
||||
DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr
|
||||
DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731"
|
||||
)
|
||||
|
||||
// Client wraps crypto/ssh Client for simplified SSH operations
|
||||
@@ -266,7 +268,7 @@ func getDefaultDaemonAddr() string {
|
||||
return addr
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows)
|
||||
return DefaultDaemonAddrWindows
|
||||
}
|
||||
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
|
||||
}
|
||||
@@ -408,9 +410,12 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey,
|
||||
}
|
||||
|
||||
func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) {
|
||||
target, opts := daemonaddr.DialTarget(daemonAddr)
|
||||
addr := strings.TrimPrefix(daemonAddr, "tcp://")
|
||||
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
conn, err := grpc.NewClient(
|
||||
addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err)
|
||||
return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -16,8 +17,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
@@ -54,8 +55,8 @@ type SSHProxy struct {
|
||||
}
|
||||
|
||||
func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) {
|
||||
target, opts := daemonaddr.DialTarget(daemonAddr)
|
||||
grpcConn, err := grpc.NewClient(target, opts...)
|
||||
grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
|
||||
grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to daemon: %w", err)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@ type CopyToClipboardProps = {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// wrap lets long content (a shell command, a path) break across lines
|
||||
// instead of being truncated to one line.
|
||||
wrap?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
tabIndex?: number;
|
||||
@@ -35,7 +32,6 @@ export const CopyToClipboard = ({
|
||||
className,
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
wrap = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
tabIndex = 0,
|
||||
@@ -87,8 +83,7 @@ export const CopyToClipboard = ({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-w-0",
|
||||
wrap ? "whitespace-pre-wrap break-all" : "truncate",
|
||||
"relative min-w-0 truncate",
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
@@ -68,21 +68,6 @@ const useSettingsState = () => {
|
||||
loadedRef.current = loaded;
|
||||
}, [loaded]);
|
||||
|
||||
// reload re-reads the daemon's config, which is authoritative. Used on
|
||||
// mount, on the daemon's config_changed event, and to undo an optimistic
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
} catch (e) {
|
||||
console.warn("[SettingsContext] reload after rejected save failed", e);
|
||||
}
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfileId) return;
|
||||
let cancelled = false;
|
||||
@@ -148,20 +133,13 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
// field, ...). Snap the controls back to what it actually
|
||||
// holds before reporting, so the UI never shows a value the
|
||||
// daemon does not have.
|
||||
await reload(profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[username, reload],
|
||||
[username],
|
||||
);
|
||||
|
||||
const setField = useCallback(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
|
||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
||||
// restricts to root/administrator. It is answered in-process from our own token
|
||||
// with the daemon's own rule, so there is no round-trip and it works while the
|
||||
// daemon is down.
|
||||
//
|
||||
// null means "not known yet", which includes the read having failed. Callers must
|
||||
// treat that as "do not restrict": the daemon enforces this regardless, so the
|
||||
// only thing a wrong guess here costs is a control that looks unavailable when it
|
||||
// is not, or a save that fails with the daemon's own guidance.
|
||||
export const usePrivilege = (): Privilege | null => {
|
||||
const [privilege, setPrivilege] = useState<Privilege | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
SettingsSvc.Privilege()
|
||||
.then((p) => {
|
||||
if (!cancelled) setPrivilege(p);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn("[usePrivilege] read failed, not restricting controls", e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return privilege;
|
||||
};
|
||||
@@ -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: "" },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string; command: string };
|
||||
type ClassifiedError = { short: string; long: string };
|
||||
|
||||
const asObject = (v: unknown): Record<string, unknown> | null =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
@@ -22,24 +22,20 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long, command } from wherever the classified error sits in the envelope
|
||||
// Read { short, long } from wherever the classified error sits in the envelope
|
||||
const toClassifiedError = (v: unknown): ClassifiedError | null => {
|
||||
const o = asObject(v);
|
||||
if (!o) return null;
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
const command = typeof o.command === "string" ? o.command : "";
|
||||
return short || long ? { short, long, command } : null;
|
||||
};
|
||||
|
||||
const classify = (e: unknown): ClassifiedError | null => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
return short || long ? { short, long } : null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified = classify(e);
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
@@ -48,26 +44,17 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
}
|
||||
|
||||
// Unclassified (a service returned the raw daemon error)
|
||||
const envelope = toWailsEnvelope(e);
|
||||
const message = envelope?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
// errorCommand returns a command the user can run to complete an operation the
|
||||
// daemon refused, when the error carries one (a change that needs elevated
|
||||
// privileges). Empty for every other error.
|
||||
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
// Command is shown for copying below the message. Defaults to the one the
|
||||
// error carries, so callers only pass it to override.
|
||||
Command?: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
@@ -13,22 +12,14 @@ import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
// A command needs the room to wrap at a sensible number of characters instead of
|
||||
// breaking every few words.
|
||||
const WINDOW_WIDTH_WITH_COMMAND = 460;
|
||||
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
// Set when the daemon refused an operation that needs elevated privileges:
|
||||
// the command that performs it, offered for copying.
|
||||
const command = params.get("command") || "";
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(
|
||||
command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH,
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
@@ -46,37 +37,15 @@ export default function ErrorDialog() {
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex w-full flex-col items-center gap-1"}>
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
|
||||
{title}
|
||||
</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
{/* select-text: the message often names a path, a flag or an
|
||||
address the user needs to act on. */}
|
||||
<span className={"select-text whitespace-pre-wrap break-words"}>
|
||||
{message}
|
||||
</span>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
{command && (
|
||||
<CopyToClipboard
|
||||
message={command}
|
||||
alwaysShowIcon
|
||||
wrap
|
||||
variant={"bright"}
|
||||
className={
|
||||
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<code
|
||||
className={"select-text break-all font-mono text-xs text-nb-gray-200"}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
@@ -7,50 +6,12 @@ import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||
import { type ChangeEvent, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch
|
||||
// (it is on, they may turn it off, but not back on) — say which, either way.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
const guarded = (
|
||||
guardedDirectionActive: boolean,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off, so
|
||||
// the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
) => {
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
||||
// Inverted control: the guarded direction is switching authentication off, so
|
||||
// it is the already-disabled state that is the one-way one.
|
||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -85,11 +46,9 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
/>
|
||||
{sshServer.hint}
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup
|
||||
@@ -99,11 +58,9 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
/>
|
||||
{sshRoot.hint}
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
@@ -131,11 +88,9 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
disabled={sshAuth.disabled}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
/>
|
||||
{sshAuth.hint}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6",
|
||||
@@ -162,42 +117,3 @@ export function SettingsSSH() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
||||
// guarded control, and offers the command that does it with the privileges the
|
||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
||||
// switching it back is the part that needs privileges.
|
||||
function PrivilegeHint({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (!command) return null;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.ssh.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
)
|
||||
@@ -35,10 +36,9 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
// Lazy on purpose: grpc.NewClient does not connect here, so a daemon that
|
||||
// is down surfaces as a per-RPC Unavailable instead of blocking the UI.
|
||||
target, opts := daemonaddr.DialTarget(daemonaddr.ResolveDaemonAddr(c.addr))
|
||||
opts = append(opts,
|
||||
cc, err := grpc.NewClient(
|
||||
strings.TrimPrefix(c.addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUserAgent(desktop.GetUIUserAgent()),
|
||||
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
|
||||
// leave the UI waiting 30-60s to notice a freshly-started daemon.
|
||||
@@ -51,8 +51,6 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
cc, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
}
|
||||
@@ -60,12 +58,10 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on
|
||||
// Linux/macOS, a named pipe on Windows. The pipe carries the caller's token,
|
||||
// which loopback TCP does not, so the daemon can tell who is calling.
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
|
||||
func DaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return daemonaddr.WindowsPipeAddr
|
||||
return "tcp://127.0.0.1:41731"
|
||||
}
|
||||
return "unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
@@ -1774,17 +1774,5 @@
|
||||
"error.unknown": {
|
||||
"message": "Operation failed.",
|
||||
"description": "Generic fallback error message used when no specific error applies."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requires {actor}. Run this instead:",
|
||||
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
||||
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
||||
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
settings := services.NewSettings(conn)
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
|
||||
// Update service is a thin Wails-bound facade over it plus the install RPCs.
|
||||
@@ -116,7 +117,6 @@ func main() {
|
||||
bundle, prefStore, localizer := buildI18n(app)
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
|
||||
// authsession.Session owns the full extend + dismiss surface the tray
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,30 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
@@ -47,10 +30,6 @@ type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
// Command is a command the user can run to complete the operation
|
||||
// themselves, set when the daemon refused it for want of privileges. The
|
||||
// frontend offers it for copying.
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Error returns the short message for plain Go callers.
|
||||
@@ -93,24 +72,6 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
msg = st.Message()
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
|
||||
// A refusal for want of privileges carries its own summary and the command
|
||||
// that performs the operation, both written for the user. Surface them
|
||||
// verbatim: no substring guessing, and no localisation of a message the
|
||||
// daemon composed.
|
||||
if info, ok := privilegeErrorInfo(err); ok {
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
summary = msg
|
||||
}
|
||||
return &ClientError{
|
||||
Code: "privilege_required",
|
||||
Short: summary,
|
||||
Long: summary,
|
||||
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,10 +7,6 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -43,19 +39,6 @@ type Restrictions struct {
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// daemon restricts to root/administrator, and carries the command for each so a
|
||||
// disabled control can show the way to do it.
|
||||
type Privilege struct {
|
||||
Privileged bool `json:"privileged"`
|
||||
// Actor names what the operation requires ("root", "administrator privileges").
|
||||
Actor string `json:"actor"`
|
||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
DisableSSHAuth string `json:"disableSshAuth"`
|
||||
}
|
||||
|
||||
type ConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
@@ -123,19 +106,11 @@ type SetConfigParams struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||
// this user and would therefore authorize us: see Privilege.
|
||||
daemonAddr string
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||
return &Settings{
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
}
|
||||
func NewSettings(conn DaemonConn) *Settings {
|
||||
return &Settings{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
@@ -214,47 +189,8 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
DisableSSHAuth: p.DisableSSHAuth,
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
// gRPC envelope, which is what a refused privileged change looks like.
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// daemon restricts to root/administrator, and the command that performs the one
|
||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
||||
// see locally, so the frontend can present those controls as unavailable up front
|
||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
||||
// daemon is down.
|
||||
//
|
||||
// Being root or an elevated administrator is one way. The other is running as the
|
||||
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||
// because such a caller can already rewrite the config it reads; that is the
|
||||
// rootless-container and Windows netstack-mode case, and it is read from the
|
||||
// ownership of the socket or pipe the daemon created.
|
||||
func (s *Settings) Privilege() Privilege {
|
||||
id, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
// Fail closed: report unprivileged, which only ever disables controls.
|
||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||
return newPrivilege(false)
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return newPrivilege(true)
|
||||
}
|
||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
}
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
}
|
||||
_, err = cli.SetConfig(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
|
||||
@@ -393,17 +393,15 @@ func (s *WindowManager) CloseWelcome() {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenError shows the custom error dialog; title/message/command are pre-localised
|
||||
// and ride in the start URL. command is optional and, when set, is offered for
|
||||
// copying so the user can run the operation the daemon refused. A second error
|
||||
// replaces the open one via SetURL. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenError(title, message, command string) {
|
||||
// OpenError shows the custom error dialog; title/message are pre-localised and ride in the
|
||||
// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenError(title, message string) {
|
||||
if ShuttingDown() {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := errorDialogURL(title, message, command)
|
||||
startURL := errorDialogURL(title, message)
|
||||
if s.errorDialog == nil {
|
||||
s.errorDialog = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
|
||||
@@ -603,8 +601,8 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorDialogURL builds the error window's start URL with title/message/command as escaped query params.
|
||||
func errorDialogURL(title, message, command string) string {
|
||||
// errorDialogURL builds the error window's start URL with title/message as escaped query params.
|
||||
func errorDialogURL(title, message string) string {
|
||||
q := url.Values{}
|
||||
if title != "" {
|
||||
q.Set("title", title)
|
||||
@@ -612,9 +610,6 @@ func errorDialogURL(title, message, command string) string {
|
||||
if message != "" {
|
||||
q.Set("message", message)
|
||||
}
|
||||
if command != "" {
|
||||
q.Set("command", command)
|
||||
}
|
||||
startURL := "/#/dialog/error"
|
||||
if enc := q.Encode(); enc != "" {
|
||||
startURL += "?" + enc
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package services
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/w32"
|
||||
|
||||
// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it
|
||||
// without a nil check when a window requests the Dark theme, crashing older
|
||||
// builds such as Windows Server 2019 (17763). Those builds still get a dark
|
||||
// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a
|
||||
// no-op stub keeps the Dark theme fully working there.
|
||||
func init() {
|
||||
if w32.AllowDarkModeForWindow == nil {
|
||||
w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 }
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,13 @@ type ServerConfig struct {
|
||||
|
||||
SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"`
|
||||
PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"`
|
||||
|
||||
AgentNetwork AgentNetworkConfig `yaml:"agentNetwork"`
|
||||
}
|
||||
|
||||
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
|
||||
type AgentNetworkConfig struct {
|
||||
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
|
||||
}
|
||||
|
||||
// TLSConfig contains TLS/HTTPS settings
|
||||
@@ -723,6 +730,9 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
||||
EmbeddedIdP: embeddedIdP,
|
||||
HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions,
|
||||
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
|
||||
AgentNetwork: nbconfig.AgentNetwork{
|
||||
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
mgmtServer "github.com/netbirdio/netbird/management/internals/server"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
@@ -288,6 +290,11 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi
|
||||
return fmt.Errorf("failed to ensure encryption key: %w", err)
|
||||
}
|
||||
|
||||
if err := loadAgentNetworkPricing(ctx, mgmtConfig); err != nil {
|
||||
cleanupSTUNListeners(s.stunListeners)
|
||||
return fmt.Errorf("failed to load agent-network pricing defaults: %w", err)
|
||||
}
|
||||
|
||||
LogConfigInfo(mgmtConfig)
|
||||
|
||||
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig)
|
||||
@@ -622,6 +629,32 @@ func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func(
|
||||
acceptFn(conn)
|
||||
}
|
||||
|
||||
// loadAgentNetworkPricing loads the management-side LLM pricing defaults
|
||||
// file for the combined server and starts its periodic reloader. An
|
||||
// explicitly configured PricingDefaultsFile is required to load (a typo
|
||||
// must fail startup rather than silently bill with built-ins the operator
|
||||
// believes they replaced); a relative path is resolved against the data
|
||||
// directory so a bare filename like "pricing.yaml" lands in the datadir
|
||||
// alongside the store. With no path configured, <datadir>/<DefaultFileName>
|
||||
// is probed and may be absent (compiled-in defaults serve).
|
||||
func loadAgentNetworkPricing(ctx context.Context, mgmtConfig *nbconfig.Config) error {
|
||||
pricingPath := mgmtConfig.AgentNetwork.PricingDefaultsFile
|
||||
required := pricingPath != ""
|
||||
if !required {
|
||||
pricingPath = agentnetworkpricing.DefaultFileName
|
||||
}
|
||||
if !filepath.IsAbs(pricingPath) {
|
||||
pricingPath = filepath.Join(mgmtConfig.Datadir, pricingPath)
|
||||
}
|
||||
|
||||
log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, required)
|
||||
if err := agentnetworkpricing.LoadFile(pricingPath, required); err != nil {
|
||||
return err
|
||||
}
|
||||
agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// logConfig prints all configuration parameters for debugging
|
||||
func logConfig(cfg *CombinedConfig) {
|
||||
log.Info("=== Configuration ===")
|
||||
@@ -698,6 +731,25 @@ func logManagementConfig(cfg *CombinedConfig) {
|
||||
log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses)
|
||||
log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL)
|
||||
}
|
||||
|
||||
logAgentNetworkConfig(cfg)
|
||||
}
|
||||
|
||||
func logAgentNetworkConfig(cfg *CombinedConfig) {
|
||||
log.Info(" Agent Network:")
|
||||
pricingPath := cfg.Server.AgentNetwork.PricingDefaultsFile
|
||||
configured := pricingPath != ""
|
||||
if !configured {
|
||||
pricingPath = agentnetworkpricing.DefaultFileName
|
||||
}
|
||||
if !filepath.IsAbs(pricingPath) {
|
||||
pricingPath = filepath.Join(cfg.Management.DataDir, pricingPath)
|
||||
}
|
||||
if configured {
|
||||
log.Infof(" Pricing defaults file: %s", pricingPath)
|
||||
} else {
|
||||
log.Infof(" Pricing defaults file: %s (default, optional)", pricingPath)
|
||||
}
|
||||
}
|
||||
|
||||
// logEnvVars logs all NB_ environment variables that are currently set
|
||||
|
||||
@@ -134,3 +134,16 @@ server:
|
||||
# trustedPeers: [] # CIDRs of trusted peer networks (e.g. ["100.64.0.0/10"])
|
||||
# accessLogRetentionDays: 7 # Days to retain HTTP access logs. 0 (or unset) defaults to 7. Negative values disable cleanup (logs kept indefinitely).
|
||||
# accessLogCleanupIntervalHours: 24 # How often (in hours) to run the access-log cleanup job. 0 (or unset) is treated as "not set" and defaults to 24 hours; cleanup remains enabled. To disable cleanup, set accessLogRetentionDays to a negative value.
|
||||
|
||||
# Agent network (LLM gateway) settings (optional)
|
||||
# agentNetwork:
|
||||
# # Path to the YAML file holding the default LLM pricing table. A relative
|
||||
# # path is resolved against dataDir, so a bare filename like "pricing.yaml"
|
||||
# # lands in the data directory. When empty, {dataDir}/defaults_llm_pricing.yaml
|
||||
# # is probed; if no file is present the compiled-in defaults are used.
|
||||
# # Schema: surface ("openai"/"anthropic"/"bedrock") -> model -> rates in USD
|
||||
# # per 1k tokens (input_per_1k, output_per_1k, and the optional
|
||||
# # cached_input_per_1k / cache_read_per_1k / cache_creation_per_1k). The file
|
||||
# # is re-read periodically (mtime poll). An explicitly configured path that
|
||||
# # fails to load fails startup; runtime reload errors keep the previous table.
|
||||
# pricingDefaultsFile: "pricing.yaml"
|
||||
|
||||
@@ -23,8 +23,13 @@ import (
|
||||
type per1k struct{ in, out, read, write float64 }
|
||||
|
||||
// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive,
|
||||
// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's
|
||||
// pricing table so a wrong embedded rate or a broken normalization fails the run.
|
||||
// keyed by the normalized model id the proxy stamps. Deliberately independent of NetBird's own
|
||||
// default pricing table so a wrong default rate or a broken normalization fails the run.
|
||||
//
|
||||
// These rates are also what providerRequest registers as the operator's per-model prices. Since
|
||||
// management now ships operator prices to the cost meter as a per-provider-record table that is
|
||||
// consulted BEFORE the surface defaults, registering the published rate is what keeps this matrix
|
||||
// asserting vendor rates — and exercises the per-record path at the same time.
|
||||
var publishedPer1k = map[string]per1k{
|
||||
"gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
|
||||
"gpt-4o": {0.0025, 0.01, 0.00125, 0},
|
||||
@@ -35,12 +40,22 @@ var publishedPer1k = map[string]per1k{
|
||||
"anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
|
||||
"anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
|
||||
"anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
|
||||
// Gateway-prefixed ids (Vercel AI Gateway, OpenRouter). A gateway model is not in
|
||||
// NetBird's default table, so before operator pricing it could only be recorded at
|
||||
// cost 0. The operator names it and prices it — at the underlying vendor's published
|
||||
// rate, which is what the gateway charges through — so these rows are now priced.
|
||||
"openai/gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
|
||||
"openai/gpt-4o": {0.0025, 0.01, 0.00125, 0},
|
||||
}
|
||||
|
||||
// rawCostVerificationSQL is the operator-facing double-check, run straight against the management
|
||||
// sqlite store: recompute each usage row's expected total and cache cost from its own persisted
|
||||
// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input;
|
||||
// Anthropic-shape providers count cache buckets additively.
|
||||
//
|
||||
// The rate rows must stay in sync with publishedPer1k — they are the same vendor rates the matrix
|
||||
// registers as operator prices. The join is on model, so rows written by other tests in this
|
||||
// package (which price their own made-up model ids) are simply not covered here.
|
||||
const rawCostVerificationSQL = `
|
||||
WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
|
||||
VALUES
|
||||
@@ -52,7 +67,9 @@ WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
|
||||
('kimi-k3', 0.003, 0.015, 0.0003, 0.003),
|
||||
('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
|
||||
('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
|
||||
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375)
|
||||
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375),
|
||||
('openai/gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0),
|
||||
('openai/gpt-4o', 0.0025, 0.01, 0.00125, 0.0)
|
||||
)
|
||||
SELECT
|
||||
u.provider,
|
||||
@@ -146,6 +163,11 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
|
||||
require.Positive(t, verified, "raw SQL check must cover at least one usage row")
|
||||
t.Logf("[sql] verified %d usage rows in store.db against published rates", verified)
|
||||
|
||||
// Gateway-prefixed model ids are absent from NetBird's default pricing table, so they are
|
||||
// priced only because the operator registered and priced them on the provider record. Assert
|
||||
// they are priced (not silently 0) — the join above already checked the exact figures for the
|
||||
// ones this matrix drives. A gateway row at cost 0 means the per-record table never reached
|
||||
// the cost meter, which is the regression this guards.
|
||||
gwRows, err := db.Raw(`SELECT model,
|
||||
(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd
|
||||
FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows()
|
||||
@@ -155,8 +177,8 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
|
||||
var model string
|
||||
var cost float64
|
||||
require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row")
|
||||
t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost)
|
||||
assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model)
|
||||
t.Logf("[sql] gateway %s: stored=$%.6f (priced from the operator's per-record rate)", model, cost)
|
||||
assert.Positivef(t, cost, "gateway-prefixed model %q is priced on the provider record, so its cost must be > 0", model)
|
||||
}
|
||||
require.NoError(t, gwRows.Err(), "iterate gateway usage rows")
|
||||
}
|
||||
@@ -177,10 +199,6 @@ func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAc
|
||||
|
||||
rates, known := publishedPer1k[model]
|
||||
if !known {
|
||||
if strings.Contains(model, "/") {
|
||||
assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model)
|
||||
return
|
||||
}
|
||||
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
|
||||
return
|
||||
}
|
||||
@@ -337,8 +355,17 @@ func availableProviders() []providerCase {
|
||||
}
|
||||
|
||||
// providerRequest builds a create request for a matrix provider: enabled, with
|
||||
// a uniquely-priced model for body-routed providers and none for the
|
||||
// path-routed Vertex (whose model lives in the request path).
|
||||
// its model registered at the vendor's published rates for body-routed
|
||||
// providers, and no models for the path-routed Vertex (whose model lives in the
|
||||
// request path, so it prices from the defaults table management ships).
|
||||
//
|
||||
// The registered rates matter: management synthesizes them into the cost
|
||||
// meter's per-provider-record table, which is consulted before the surface
|
||||
// defaults, so these are the rates the proxy actually bills with. Registering
|
||||
// the published rate keeps the cost assertions vendor-anchored while covering
|
||||
// the operator-pricing path. A model with no published rate on file (an
|
||||
// env-overridden Bedrock profile) falls back to a nominal rate, and
|
||||
// validateAccessLogCost skips its cost check.
|
||||
func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||||
req := api.AgentNetworkProviderRequest{
|
||||
Name: pc.name,
|
||||
@@ -356,9 +383,23 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||||
if pc.kind == harness.WireBedrock {
|
||||
modelID = catalogModel(pc)
|
||||
}
|
||||
req.Models = &[]api.AgentNetworkProviderModel{
|
||||
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
model := api.AgentNetworkProviderModel{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}
|
||||
if rates, known := publishedPer1k[catalogModel(pc)]; known {
|
||||
model.InputPer1k = rates.in
|
||||
model.OutputPer1k = rates.out
|
||||
// Pin the cache rates too, rather than letting them inherit from the
|
||||
// defaults table: a gateway-prefixed id has no default entry to
|
||||
// inherit from, and an unset rate bills that bucket at the input
|
||||
// rate, which would not match the published-rate recompute.
|
||||
if rates.read > 0 {
|
||||
model.CachedInputPer1k = ptr(rates.read) // OpenAI shape
|
||||
model.CacheReadPer1k = ptr(rates.read) // Anthropic / Bedrock shape
|
||||
}
|
||||
if rates.write > 0 {
|
||||
model.CacheCreationPer1k = ptr(rates.write)
|
||||
}
|
||||
}
|
||||
req.Models = &[]api.AgentNetworkProviderModel{model}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
447
e2e/agentnetwork/custom_pricing_test.go
Normal file
447
e2e/agentnetwork/custom_pricing_test.go
Normal file
@@ -0,0 +1,447 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// The mock vLLM upstream (harness/vllm.go) always answers with this fixed usage
|
||||
// block, so every request drives deterministic token counts regardless of the
|
||||
// model the client asks for. The proxy prices off the REQUEST model, not the
|
||||
// upstream response model, so a made-up model id billed at operator rates lets
|
||||
// these tests assert exact costs without a real vendor key.
|
||||
const (
|
||||
vllmPromptTokens = 11
|
||||
vllmCompletionTokens = 2
|
||||
)
|
||||
|
||||
// pricedEnv is a connected single-provider agent-network deployment pointed at
|
||||
// the mock vLLM upstream, with the proxy and client up and the endpoint resolved
|
||||
// — ready to drive chat. All containers are torn down via t.Cleanup.
|
||||
type pricedEnv struct {
|
||||
provID string
|
||||
upstream string // provider upstream URL, needed to re-send on a PUT update
|
||||
endpoint string
|
||||
proxyIP string
|
||||
client *harness.Client
|
||||
proxy *harness.Proxy
|
||||
}
|
||||
|
||||
// provisionPricedProvider brings up the full path for a cost test: a mock vLLM
|
||||
// upstream, a group + reusable setup key, one openai_api provider pointed at the
|
||||
// mock enumerating exactly the given models (with the operator's per-1k prices),
|
||||
// a policy whose token limit switches on usage metering, and a connected proxy +
|
||||
// client. The provider is created with the given models so the router dispatches
|
||||
// them to this provider and the cost meter bills at these rates.
|
||||
//
|
||||
// Passing nil models makes it a gateway-style catch-all: the router claims every
|
||||
// model, and since the synthesizer ships no per-provider-record pricing entry
|
||||
// for a provider that enumerates nothing, the shipped defaults table is the only
|
||||
// thing that can price the request. The policy sets no model guardrail, so the
|
||||
// proxy's per-provider allowlist backstop stays empty and any model routes.
|
||||
func provisionPricedProvider(t *testing.T, ctx context.Context, name string, models []api.AgentNetworkProviderModel) pricedEnv {
|
||||
t.Helper()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock vLLM upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-price-" + name})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-price-" + name + "-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
// The mock ignores auth, so a dummy key satisfies the "Bearer ${API_KEY}"
|
||||
// template. openai_api is a known catalog provider; the enumerated model id
|
||||
// need NOT be in the catalog — the operator names it and prices it here.
|
||||
dummyKey := "sk-price-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Models: &models,
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
// Uncapped token limit: never blocks the handful of tokens driven here, but
|
||||
// switches on usage metering — the switch that makes consumption rows record.
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-price-" + name,
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
Limits: &api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: 10_000_000,
|
||||
UserCap: 10_000_000,
|
||||
WindowSeconds: 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
settings, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "read settings")
|
||||
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
|
||||
|
||||
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-price-"+name+"-proxy")
|
||||
require.NoError(t, err, "mint proxy token")
|
||||
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
||||
require.NoError(t, err, "start proxy")
|
||||
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
||||
|
||||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Probe first: the GET resolves the endpoint and its first packet wakes the
|
||||
// lazy proxy peer, so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
|
||||
return pricedEnv{provID: prov.Id, upstream: vllm.URL, endpoint: settings.Endpoint, proxyIP: proxyIP, client: cl, proxy: px}
|
||||
}
|
||||
|
||||
// chatOnce drives one OpenAI-shaped chat for model through the tunnel, retrying
|
||||
// to absorb first-call tunnel/DNS jitter, and returns the response body.
|
||||
func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID string) string {
|
||||
t.Helper()
|
||||
var code int
|
||||
var body string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, b, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, model, "Reply with exactly: pong", sessionID)
|
||||
if cerr == nil {
|
||||
code, body = c, b
|
||||
if code == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
require.Equal(t, 200, code,
|
||||
"chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background()))
|
||||
return body
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying sessionID.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
var row api.AgentNetworkAccessLog
|
||||
require.Eventually(t, func() bool {
|
||||
logs, lerr := srv.ListAccessLogs(ctx)
|
||||
if lerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
row = r
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID)
|
||||
return row
|
||||
}
|
||||
|
||||
// assertOpenAICostAtRates asserts an access-log row's token counts and every cost
|
||||
// bucket match the mock's fixed usage priced at the given operator rates. The
|
||||
// openai surface has no cache-write bucket and the mock reports no cache tokens,
|
||||
// so the whole cost is input + output; cache costs must be exactly zero.
|
||||
func assertOpenAICostAtRates(t *testing.T, row api.AgentNetworkAccessLog, inRate, outRate float64) {
|
||||
t.Helper()
|
||||
wantInput := float64(vllmPromptTokens) / 1000 * inRate
|
||||
wantOutput := float64(vllmCompletionTokens) / 1000 * outRate
|
||||
wantTotal := wantInput + wantOutput
|
||||
|
||||
model := ""
|
||||
if row.Model != nil {
|
||||
model = *row.Model
|
||||
}
|
||||
t.Logf("[cost] model=%s in=%d out=%d rates in/out=%.4f/%.4f stored input/output/total=$%.6f/$%.6f/$%.6f expected input/output/total=$%.6f/$%.6f/$%.6f",
|
||||
model, row.InputTokens, row.OutputTokens, inRate, outRate,
|
||||
row.InputCostUsd, row.OutputCostUsd, row.CostUsd, wantInput, wantOutput, wantTotal)
|
||||
|
||||
assert.EqualValues(t, vllmPromptTokens, row.InputTokens, "prompt tokens from the mock usage block")
|
||||
assert.EqualValues(t, vllmCompletionTokens, row.OutputTokens, "completion tokens from the mock usage block")
|
||||
assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "input_cost_usd must be prompt tokens at the operator input rate")
|
||||
assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "output_cost_usd must be completion tokens at the operator output rate")
|
||||
assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "cost_usd must be the sum of the priced buckets")
|
||||
assert.Zerof(t, row.CachedInputCostUsd, "no cache-read tokens, so cached_input_cost_usd must be 0")
|
||||
assert.Zerof(t, row.CacheCreationCostUsd, "openai surface has no cache-write bucket, so cache_creation_cost_usd must be 0")
|
||||
assert.Zerof(t, row.CacheCostUsd, "no cache usage, so cache_cost_usd must be 0")
|
||||
assert.InDeltaf(t, row.InputCostUsd+row.OutputCostUsd, row.CostUsd, 1e-9, "stored buckets must sum to cost_usd")
|
||||
}
|
||||
|
||||
// verifyUsageRowForSession re-checks the persisted usage row for a session
|
||||
// directly in the management sqlite store — the same audit an operator runs on a
|
||||
// production store.db — asserting its cost buckets match the operator rates.
|
||||
func verifyUsageRowForSession(t *testing.T, sessionID string, inRate, outRate float64) {
|
||||
t.Helper()
|
||||
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
|
||||
require.NoError(t, err, "snapshot management sqlite store")
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err, "open store snapshot")
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
var provider, model string
|
||||
var inTok, outTok, cachedTok, cacheCreateTok int64
|
||||
var inCost, cachedInCost, cacheCreateCost, outCost float64
|
||||
row := db.Raw(`SELECT provider, model, input_tokens, output_tokens, cached_input_tokens, cache_creation_tokens,
|
||||
input_cost_usd, cached_input_cost_usd, cache_creation_cost_usd, output_cost_usd
|
||||
FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).Row()
|
||||
require.NoError(t, row.Scan(&provider, &model, &inTok, &outTok, &cachedTok, &cacheCreateTok,
|
||||
&inCost, &cachedInCost, &cacheCreateCost, &outCost),
|
||||
"a usage row must exist for session %q", sessionID)
|
||||
|
||||
wantInput := float64(inTok) / 1000 * inRate
|
||||
wantOutput := float64(outTok) / 1000 * outRate
|
||||
t.Logf("[sql] session=%s %s/%s in=%d out=%d stored input/cached/create/output=$%.6f/$%.6f/$%.6f/$%.6f",
|
||||
sessionID, provider, model, inTok, outTok, inCost, cachedInCost, cacheCreateCost, outCost)
|
||||
assert.EqualValues(t, vllmPromptTokens, inTok, "usage row prompt tokens")
|
||||
assert.EqualValues(t, vllmCompletionTokens, outTok, "usage row completion tokens")
|
||||
assert.InDeltaf(t, wantInput, inCost, 1e-6, "usage input_cost_usd must be prompt tokens at the operator input rate")
|
||||
assert.InDeltaf(t, wantOutput, outCost, 1e-6, "usage output_cost_usd must be completion tokens at the operator output rate")
|
||||
assert.Zerof(t, cachedInCost, "usage cached_input_cost_usd must be 0 (no cache usage)")
|
||||
assert.Zerof(t, cacheCreateCost, "usage cache_creation_cost_usd must be 0 (no cache usage)")
|
||||
}
|
||||
|
||||
// TestCustomModelPricing proves an operator can serve a model that is NOT in
|
||||
// NetBird's compiled catalog, at prices they type themselves, and that those
|
||||
// operator prices drive the recorded cost end to end — access log AND usage
|
||||
// ledger. The provider enumerates one made-up model id at deliberately odd rates
|
||||
// (no default entry could supply them), the client requests it, and every cost
|
||||
// bucket must equal the mock's fixed token counts multiplied by those rates.
|
||||
func TestCustomModelPricing(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
const (
|
||||
customModel = "e2e-custom-model" // absent from the compiled catalog
|
||||
inRate = 0.037 // odd rates so a stray default can't match
|
||||
outRate = 0.089
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "custommodel", []api.AgentNetworkProviderModel{
|
||||
{Id: customModel, InputPer1k: inRate, OutputPer1k: outRate},
|
||||
})
|
||||
|
||||
sessionID := "e2e-session-custommodel"
|
||||
body := chatOnce(t, ctx, env, customModel, sessionID)
|
||||
require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
require.NotNil(t, row.Model, "access-log row must carry the requested model")
|
||||
assert.Equal(t, customModel, *row.Model, "the row must be stamped with the requested (custom) model, not the mock's response model")
|
||||
assertOpenAICostAtRates(t, row, inRate, outRate)
|
||||
|
||||
// Metering: the uncapped token limit switches on usage recording, so the
|
||||
// request must surface as a consumption row with positive tokens and cost.
|
||||
require.Eventually(t, func() bool {
|
||||
rows, lerr := srv.ListConsumption(ctx)
|
||||
if lerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.TokensInput > 0 && r.TokensOutput > 0 && r.CostUsd > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 60*time.Second, 3*time.Second, "custom-model usage must be metered into a consumption row with positive cost")
|
||||
|
||||
// Final raw-SQL audit: bypass the API and re-verify the persisted usage row.
|
||||
verifyUsageRowForSession(t, sessionID, inRate, outRate)
|
||||
}
|
||||
|
||||
// TestPriceChangeUpdatesRecordedCost proves that changing a provider's model
|
||||
// price is reflected in the cost recorded for subsequent requests — in both the
|
||||
// access log and the usage ledger — while requests already priced at the old
|
||||
// rate keep their original cost. The update propagates to the connected proxy
|
||||
// live (a mapping push rebuilds the cost_meter chain with the new table), so no
|
||||
// reconnect or restart is needed; the test polls a fresh request until the new
|
||||
// rate lands.
|
||||
func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
const (
|
||||
customModel = "e2e-repriced-model"
|
||||
inRateA = 0.010
|
||||
outRateA = 0.020
|
||||
inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable
|
||||
outRateB = 0.080
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{
|
||||
{Id: customModel, InputPer1k: inRateA, OutputPer1k: outRateA},
|
||||
})
|
||||
|
||||
// Phase 1 — request priced at the original rate A.
|
||||
sessionA := "e2e-session-reprice-a"
|
||||
chatOnce(t, ctx, env, customModel, sessionA)
|
||||
rowA := findAccessLogBySession(t, ctx, sessionA)
|
||||
assertOpenAICostAtRates(t, rowA, inRateA, outRateA)
|
||||
verifyUsageRowForSession(t, sessionA, inRateA, outRateA)
|
||||
|
||||
// Change the model's price. The API key is omitted so the stored one is kept;
|
||||
// the models array is re-sent with the new rates (PUT replaces the list).
|
||||
// This reconciles synchronously and pushes a fresh cost_meter table to the
|
||||
// already-connected proxy — no reconnect.
|
||||
_, err := srv.UpdateProvider(ctx, env.provID, api.AgentNetworkProviderRequest{
|
||||
Name: "reprice",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: env.upstream,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: customModel, InputPer1k: inRateB, OutputPer1k: outRateB},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "update provider price")
|
||||
|
||||
// Phase 2 — the push + chain rebuild is async, so drive fresh requests (each
|
||||
// under its own session) until one is priced at the new rate B. Each iteration
|
||||
// fires one request and waits for that session's row to be ingested before
|
||||
// reading its cost, so an un-ingested row is never mistaken for "still rate A".
|
||||
// The expected new input cost is unmistakably higher than rate A, so a
|
||||
// lingering old-rate row can't satisfy the check.
|
||||
wantInputB := float64(vllmPromptTokens) / 1000 * inRateB
|
||||
var repriced api.AgentNetworkAccessLog
|
||||
var lastSession string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano())
|
||||
code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
|
||||
if cerr != nil || code != 200 {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
row := findAccessLogBySession(t, ctx, lastSession)
|
||||
if inDelta(row.InputCostUsd, wantInputB, 1e-6) {
|
||||
repriced = row
|
||||
break
|
||||
}
|
||||
// Still priced at the old rate — the push hasn't landed yet; retry.
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s",
|
||||
repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background()))
|
||||
|
||||
assertOpenAICostAtRates(t, repriced, inRateB, outRateB)
|
||||
verifyUsageRowForSession(t, lastSession, inRateB, outRateB)
|
||||
|
||||
// The original request keeps its original cost: repricing is not retroactive.
|
||||
rowAStill := findAccessLogBySession(t, ctx, sessionA)
|
||||
assertOpenAICostAtRates(t, rowAStill, inRateA, outRateA)
|
||||
verifyUsageRowForSession(t, sessionA, inRateA, outRateA)
|
||||
}
|
||||
|
||||
// TestPricingDefaultsFileDrivesCost proves the operator-supplied pricing
|
||||
// defaults file is what the proxy bills with. The harness configures
|
||||
// server.agentNetwork.pricingDefaultsFile as a BARE FILENAME and writes that
|
||||
// file into the bind-mounted datadir (see harness.PricingDefaultsFileName), so a
|
||||
// pass exercises the whole chain: combined yaml → ToManagementConfig →
|
||||
// pricing.LoadFile (relative path resolved against datadir) → DefaultTable →
|
||||
// the synthesizer's cost_meter defaults payload → the proxy's lookup.
|
||||
//
|
||||
// The provider enumerates NO models, so it is a catch-all route with no
|
||||
// per-provider-record pricing entry at all — the only rates that can price the
|
||||
// request are the shipped defaults. The model is a real catalog model whose
|
||||
// built-in rates the file replaces with deliberately odd values, so billing at
|
||||
// the compiled-in rates (i.e. the file never loaded) fails the assertions.
|
||||
func TestPricingDefaultsFileDrivesCost(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// nil models: a gateway-style provider claiming every model. The synthesizer
|
||||
// ships no per-record entry for it, so the defaults table is its price list.
|
||||
env := provisionPricedProvider(t, ctx, "defaultsfile", nil)
|
||||
|
||||
sessionID := "e2e-session-defaultsfile"
|
||||
body := chatOnce(t, ctx, env, harness.PricedDefaultModel, sessionID)
|
||||
require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
require.NotNil(t, row.Model, "access-log row must carry the requested model")
|
||||
assert.Equal(t, harness.PricedDefaultModel, *row.Model, "the row must be stamped with the requested model")
|
||||
|
||||
// The file's rates, not the compiled-in catalog rates for this model.
|
||||
assertOpenAICostAtRates(t, row, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k)
|
||||
verifyUsageRowForSession(t, sessionID, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k)
|
||||
}
|
||||
|
||||
// TestPricingDefaultsFileLeavesOtherModelsAlone proves the defaults file merges
|
||||
// per entry rather than replacing the whole table: the file names exactly one
|
||||
// model, so a DIFFERENT catalog model must still bill at its compiled-in rates.
|
||||
// Without this, a file that shipped as a wholesale replacement would silently
|
||||
// zero-cost every model the operator didn't list, and TestPricingDefaultsFile-
|
||||
// DrivesCost alone would not notice.
|
||||
func TestPricingDefaultsFileLeavesOtherModelsAlone(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// gpt-4o-mini is a catalog model the pricing file does NOT mention, so it must
|
||||
// keep its built-in rates. Pinned here independently of the catalog source so
|
||||
// a rate change in either place surfaces as a failure to reconcile rather
|
||||
// than passing silently.
|
||||
const (
|
||||
untouchedModel = "gpt-4o-mini"
|
||||
builtinInRate = 0.00015
|
||||
builtinOutRate = 0.0006
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "defaultsfileother", nil)
|
||||
|
||||
sessionID := "e2e-session-defaultsfile-other"
|
||||
chatOnce(t, ctx, env, untouchedModel, sessionID)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
assertOpenAICostAtRates(t, row, builtinInRate, builtinOutRate)
|
||||
verifyUsageRowForSession(t, sessionID, builtinInRate, builtinOutRate)
|
||||
}
|
||||
|
||||
// inDelta reports whether a and b are within tol of each other.
|
||||
func inDelta(a, b, tol float64) bool {
|
||||
d := a - b
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d <= tol
|
||||
}
|
||||
@@ -74,6 +74,13 @@ func (c *Combined) DeleteProvider(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/providers/"+id)
|
||||
}
|
||||
|
||||
// UpdateProvider replaces a provider by id (PUT). The API key may be omitted on
|
||||
// the request to keep the stored one; Models replaces the enumerated list, so
|
||||
// this is the path a test uses to change a model's price mid-run.
|
||||
func (c *Combined) UpdateProvider(ctx context.Context, id string, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) {
|
||||
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, req)
|
||||
}
|
||||
|
||||
// SetProviderEnabled toggles a provider's enabled flag, preserving its other
|
||||
// fields (the API key is omitted, which keeps the stored one). Used to run one
|
||||
// provider at a time so model→provider routing is unambiguous.
|
||||
|
||||
@@ -93,10 +93,19 @@ func StartCombined(ctx context.Context) (*Combined, error) {
|
||||
_ = net.Remove(ctx)
|
||||
return nil, fmt.Errorf("write combined config: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "data"), 0o755); err != nil {
|
||||
dataDir := filepath.Join(workDir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
_ = net.Remove(ctx)
|
||||
return nil, fmt.Errorf("create datadir: %w", err)
|
||||
}
|
||||
// The config's agentNetwork.pricingDefaultsFile is a bare filename, so the
|
||||
// server resolves it against the datadir; write it there. It is an explicitly
|
||||
// configured path, so a failure to load fails the server's startup — which
|
||||
// surfaces here as the /api/instance readiness wait timing out.
|
||||
if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
|
||||
_ = net.Remove(ctx)
|
||||
return nil, fmt.Errorf("write pricing defaults: %w", err)
|
||||
}
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: combinedImage,
|
||||
|
||||
@@ -8,6 +8,13 @@ package harness
|
||||
// embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted
|
||||
// data dir. exposedAddress is the address peers use to reach this container; it
|
||||
// is overridden per-run so the value matches the container's network alias.
|
||||
//
|
||||
// pricingDefaultsFile is deliberately a BARE FILENAME, not an absolute path: it
|
||||
// must resolve against dataDir (→ /nb/data/<name>), which is the resolution rule
|
||||
// the combined server applies. It is also an EXPLICITLY configured path, so the
|
||||
// server is required to load it — a broken path or malformed file fails startup
|
||||
// rather than silently falling back to the compiled-in rates, and TestMain then
|
||||
// fails with the container logs.
|
||||
const combinedConfigYAML = `server:
|
||||
listenAddress: ":8080"
|
||||
exposedAddress: "%s"
|
||||
@@ -23,4 +30,36 @@ const combinedConfigYAML = `server:
|
||||
issuer: "%s"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
agentNetwork:
|
||||
pricingDefaultsFile: "` + PricingDefaultsFileName + `"
|
||||
`
|
||||
|
||||
const (
|
||||
// PricingDefaultsFileName is the basename of the operator-supplied LLM
|
||||
// pricing defaults file the combined server is configured to load. Written
|
||||
// into the bind-mounted datadir by StartCombined.
|
||||
PricingDefaultsFileName = "e2e_llm_pricing.yaml"
|
||||
|
||||
// PricedDefaultModel is a real catalog model (openai surface) whose rates the
|
||||
// defaults file below REPLACES. Tests drive it against the mock vLLM upstream
|
||||
// and assert the file's rates were billed, which is only true if the file
|
||||
// travelled: config → LoadFile → DefaultTable → synthesizer → the proxy's
|
||||
// cost_meter defaults table.
|
||||
PricedDefaultModel = "gpt-4.1-mini"
|
||||
// PricedDefaultInputPer1k / PricedDefaultOutputPer1k are deliberately odd
|
||||
// values that no compiled-in catalog entry carries (gpt-4.1-mini ships as
|
||||
// 0.0004 / 0.0016), so a test asserting them cannot pass on the built-in
|
||||
// table.
|
||||
PricedDefaultInputPer1k = 0.0123
|
||||
PricedDefaultOutputPer1k = 0.0456
|
||||
)
|
||||
|
||||
// pricingDefaultsYAML is the operator-supplied pricing defaults file. Its schema
|
||||
// is surface -> model -> per-1k rates. Entries replace the compiled-in entry for
|
||||
// the same surface+model whole; every other model keeps its built-in rates, so
|
||||
// this file overriding one model must not disturb the rest of the table.
|
||||
const pricingDefaultsYAML = `openai:
|
||||
gpt-4.1-mini:
|
||||
input_per_1k: 0.0123
|
||||
output_per_1k: 0.0456
|
||||
`
|
||||
|
||||
4
go.mod
4
go.mod
@@ -30,7 +30,6 @@ require (
|
||||
|
||||
require (
|
||||
github.com/DeRuina/timberjack v1.4.2
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1
|
||||
@@ -134,7 +133,6 @@ require (
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.276.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
@@ -158,6 +156,7 @@ require (
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
@@ -318,6 +317,7 @@ require (
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
@@ -1,19 +1,557 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/bash
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
ERROR: This legacy installation script has been retired and no longer runs.
|
||||
set -e
|
||||
|
||||
Dex support is not deprecated. For new deployments, use getting-started.sh:
|
||||
# NetBird Getting Started with Dex IDP
|
||||
# This script sets up NetBird with Dex as the identity provider
|
||||
|
||||
https://docs.netbird.io/selfhosted/selfhosted-quickstart
|
||||
# Sed pattern to strip base64 padding characters
|
||||
SED_STRIP_PADDING='s/=//g'
|
||||
|
||||
The current installer includes NetBird's embedded Dex-based identity provider.
|
||||
Local users and external identity providers can be managed through the
|
||||
NetBird Dashboard:
|
||||
check_docker_compose() {
|
||||
if command -v docker-compose &> /dev/null
|
||||
then
|
||||
echo "docker-compose"
|
||||
return
|
||||
fi
|
||||
if docker compose --help &> /dev/null
|
||||
then
|
||||
echo "docker compose"
|
||||
return
|
||||
fi
|
||||
|
||||
https://docs.netbird.io/selfhosted/identity-providers/local
|
||||
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
|
||||
exit 1
|
||||
}
|
||||
|
||||
This compatibility notice will be removed in NetBird v0.80.
|
||||
check_jq() {
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
get_main_ip_address() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
interface=$(route -n get default | grep 'interface:' | awk '{print $2}')
|
||||
ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}')
|
||||
else
|
||||
interface=$(ip route | grep default | awk '{print $5}' | head -n 1)
|
||||
ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
echo "$ip_address"
|
||||
return 0
|
||||
}
|
||||
|
||||
check_nb_domain() {
|
||||
DOMAIN=$1
|
||||
if [[ "$DOMAIN-x" == "-x" ]]; then
|
||||
echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$DOMAIN" == "netbird.example.com" ]]; then
|
||||
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
read_nb_domain() {
|
||||
READ_NETBIRD_DOMAIN=""
|
||||
echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr
|
||||
read -r READ_NETBIRD_DOMAIN < /dev/tty
|
||||
if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then
|
||||
read_nb_domain
|
||||
fi
|
||||
echo "$READ_NETBIRD_DOMAIN"
|
||||
return 0
|
||||
}
|
||||
|
||||
get_turn_external_ip() {
|
||||
TURN_EXTERNAL_IP_CONFIG="#external-ip="
|
||||
IP=$(curl -s -4 https://jsonip.com | jq -r '.ip')
|
||||
if [[ "x-$IP" != "x-" ]]; then
|
||||
TURN_EXTERNAL_IP_CONFIG="external-ip=$IP"
|
||||
fi
|
||||
echo "$TURN_EXTERNAL_IP_CONFIG"
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_dex() {
|
||||
set +e
|
||||
echo -n "Waiting for Dex to become ready (via $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN)"
|
||||
counter=1
|
||||
while true; do
|
||||
# Check Dex through Caddy proxy (also validates TLS is working)
|
||||
if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [[ $counter -eq 60 ]]; then
|
||||
echo ""
|
||||
echo "Taking too long. Checking logs..."
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=20 caddy
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=20 dex
|
||||
fi
|
||||
echo -n " ."
|
||||
sleep 2
|
||||
counter=$((counter + 1))
|
||||
done
|
||||
echo " done"
|
||||
set -e
|
||||
return 0
|
||||
}
|
||||
|
||||
init_environment() {
|
||||
CADDY_SECURE_DOMAIN=""
|
||||
NETBIRD_PORT=80
|
||||
NETBIRD_HTTP_PROTOCOL="http"
|
||||
NETBIRD_RELAY_PROTO="rel"
|
||||
TURN_USER="self"
|
||||
TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
||||
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
||||
TURN_MIN_PORT=49152
|
||||
TURN_MAX_PORT=65535
|
||||
TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip)
|
||||
|
||||
# Generate secrets for Dex
|
||||
DEX_DASHBOARD_CLIENT_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
||||
|
||||
# Generate admin password
|
||||
NETBIRD_ADMIN_PASSWORD=$(openssl rand -base64 16 | sed "$SED_STRIP_PADDING")
|
||||
|
||||
if ! check_nb_domain "$NETBIRD_DOMAIN"; then
|
||||
NETBIRD_DOMAIN=$(read_nb_domain)
|
||||
fi
|
||||
|
||||
if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then
|
||||
NETBIRD_DOMAIN=$(get_main_ip_address)
|
||||
else
|
||||
NETBIRD_PORT=443
|
||||
CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||
NETBIRD_HTTP_PROTOCOL="https"
|
||||
NETBIRD_RELAY_PROTO="rels"
|
||||
fi
|
||||
|
||||
check_jq
|
||||
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
if [[ -f dex.yaml ]]; then
|
||||
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
|
||||
echo "You can use the following commands:"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
|
||||
echo " rm -f docker-compose.yml Caddyfile dex.yaml dashboard.env turnserver.conf management.json relay.env"
|
||||
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo Rendering initial files...
|
||||
render_docker_compose > docker-compose.yml
|
||||
render_caddyfile > Caddyfile
|
||||
render_dex_config > dex.yaml
|
||||
render_dashboard_env > dashboard.env
|
||||
render_management_json > management.json
|
||||
render_turn_server_conf > turnserver.conf
|
||||
render_relay_env > relay.env
|
||||
|
||||
echo -e "\nStarting Dex IDP\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d caddy dex
|
||||
|
||||
# Wait for Dex to be ready (through caddy proxy)
|
||||
sleep 3
|
||||
wait_dex
|
||||
|
||||
echo -e "\nStarting NetBird services\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d
|
||||
|
||||
echo -e "\nDone!\n"
|
||||
echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
|
||||
echo ""
|
||||
echo "Login with the following credentials:"
|
||||
install -m 600 /dev/null .env
|
||||
printf 'Email: admin@%s\nPassword: %s\n' \
|
||||
"$NETBIRD_DOMAIN" "$NETBIRD_ADMIN_PASSWORD" >> .env
|
||||
echo "Email: admin@$NETBIRD_DOMAIN"
|
||||
echo "Password: $NETBIRD_ADMIN_PASSWORD"
|
||||
echo ""
|
||||
echo "Dex admin UI is not available (Dex has no built-in UI)."
|
||||
echo "To add more users, edit dex.yaml and restart: $DOCKER_COMPOSE_COMMAND restart dex"
|
||||
return 0
|
||||
}
|
||||
|
||||
render_caddyfile() {
|
||||
cat <<EOF
|
||||
{
|
||||
debug
|
||||
servers :80,:443 {
|
||||
protocols h1 h2c h2 h3
|
||||
}
|
||||
}
|
||||
|
||||
(security_headers) {
|
||||
header * {
|
||||
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
-Server
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
}
|
||||
|
||||
:80${CADDY_SECURE_DOMAIN} {
|
||||
import security_headers
|
||||
# Relay
|
||||
reverse_proxy /relay* relay:80
|
||||
# Signal
|
||||
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
|
||||
# Management
|
||||
reverse_proxy /api/* management:80
|
||||
reverse_proxy /management.ManagementService/* h2c://management:80
|
||||
# Dex
|
||||
reverse_proxy /dex/* dex:5556
|
||||
# Dashboard
|
||||
reverse_proxy /* dashboard:80
|
||||
}
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
exit 1
|
||||
render_dex_config() {
|
||||
# Generate bcrypt hash of the admin password
|
||||
# Using a simple approach - htpasswd or python if available
|
||||
ADMIN_PASSWORD_HASH=""
|
||||
if command -v htpasswd &> /dev/null; then
|
||||
ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$NETBIRD_ADMIN_PASSWORD" | tr -d ':\n')
|
||||
elif command -v python3 &> /dev/null; then
|
||||
ADMIN_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$NETBIRD_ADMIN_PASSWORD'.encode(), bcrypt.gensalt(rounds=10)).decode())" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Fallback to a known hash if we can't generate one
|
||||
if [[ -z "$ADMIN_PASSWORD_HASH" ]]; then
|
||||
# This is hash of "password" - user should change it
|
||||
ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
|
||||
NETBIRD_ADMIN_PASSWORD="password"
|
||||
echo "Warning: Could not generate password hash. Using default password: password. Please change it in dex.yaml" > /dev/stderr
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# Dex configuration for NetBird
|
||||
# Generated by getting-started-with-dex.sh
|
||||
|
||||
issuer: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex
|
||||
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: /var/dex/dex.db
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
|
||||
# gRPC API for user management (used by NetBird IDP manager)
|
||||
grpc:
|
||||
addr: 0.0.0.0:5557
|
||||
|
||||
oauth2:
|
||||
skipApprovalScreen: true
|
||||
|
||||
# Static OAuth2 clients for NetBird
|
||||
staticClients:
|
||||
# Dashboard client
|
||||
- id: netbird-dashboard
|
||||
name: NetBird Dashboard
|
||||
secret: $DEX_DASHBOARD_CLIENT_SECRET
|
||||
redirectURIs:
|
||||
- $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth
|
||||
- $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth
|
||||
|
||||
# CLI client (public - uses PKCE)
|
||||
- id: netbird-cli
|
||||
name: NetBird CLI
|
||||
public: true
|
||||
redirectURIs:
|
||||
- http://localhost:53000/
|
||||
- http://localhost:54000/
|
||||
|
||||
# Enable password database for static users
|
||||
enablePasswordDB: true
|
||||
|
||||
# Static users - add more users here as needed
|
||||
staticPasswords:
|
||||
- email: "admin@$NETBIRD_DOMAIN"
|
||||
hash: "$ADMIN_PASSWORD_HASH"
|
||||
username: "admin"
|
||||
userID: "$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "admin-user-id-001")"
|
||||
|
||||
# Optional: Add external identity provider connectors
|
||||
# connectors:
|
||||
# - type: github
|
||||
# id: github
|
||||
# name: GitHub
|
||||
# config:
|
||||
# clientID: \$GITHUB_CLIENT_ID
|
||||
# clientSecret: \$GITHUB_CLIENT_SECRET
|
||||
# redirectURI: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/callback
|
||||
#
|
||||
# - type: ldap
|
||||
# id: ldap
|
||||
# name: LDAP
|
||||
# config:
|
||||
# host: ldap.example.com:636
|
||||
# insecureNoSSL: false
|
||||
# bindDN: cn=admin,dc=example,dc=com
|
||||
# bindPW: admin
|
||||
# userSearch:
|
||||
# baseDN: ou=users,dc=example,dc=com
|
||||
# filter: "(objectClass=person)"
|
||||
# username: uid
|
||||
# idAttr: uid
|
||||
# emailAttr: mail
|
||||
# nameAttr: cn
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_turn_server_conf() {
|
||||
cat <<EOF
|
||||
listening-port=3478
|
||||
$TURN_EXTERNAL_IP_CONFIG
|
||||
tls-listening-port=5349
|
||||
min-port=$TURN_MIN_PORT
|
||||
max-port=$TURN_MAX_PORT
|
||||
fingerprint
|
||||
lt-cred-mech
|
||||
user=$TURN_USER:$TURN_PASSWORD
|
||||
realm=wiretrustee.com
|
||||
cert=/etc/coturn/certs/cert.pem
|
||||
pkey=/etc/coturn/private/privkey.pem
|
||||
log-file=stdout
|
||||
no-software-attribute
|
||||
pidfile="/var/tmp/turnserver.pid"
|
||||
no-cli
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_management_json() {
|
||||
cat <<EOF
|
||||
{
|
||||
"Stuns": [
|
||||
{
|
||||
"Proto": "udp",
|
||||
"URI": "stun:$NETBIRD_DOMAIN:3478"
|
||||
}
|
||||
],
|
||||
"Relay": {
|
||||
"Addresses": ["$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT"],
|
||||
"CredentialsTTL": "24h",
|
||||
"Secret": "$NETBIRD_RELAY_AUTH_SECRET"
|
||||
},
|
||||
"Signal": {
|
||||
"Proto": "$NETBIRD_HTTP_PROTOCOL",
|
||||
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||
},
|
||||
"HttpConfig": {
|
||||
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex",
|
||||
"AuthAudience": "netbird-dashboard",
|
||||
"OIDCConfigEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration"
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "dex",
|
||||
"ClientConfig": {
|
||||
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex"
|
||||
},
|
||||
"ExtraConfig": {
|
||||
"GRPCAddr": "dex:5557"
|
||||
}
|
||||
},
|
||||
"DeviceAuthorizationFlow": {
|
||||
"Provider": "hosted",
|
||||
"ProviderConfig": {
|
||||
"Audience": "netbird-cli",
|
||||
"ClientID": "netbird-cli",
|
||||
"Scope": "openid profile email offline_access",
|
||||
"DeviceAuthEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/device/code",
|
||||
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/token"
|
||||
}
|
||||
},
|
||||
"PKCEAuthorizationFlow": {
|
||||
"ProviderConfig": {
|
||||
"Audience": "netbird-cli",
|
||||
"ClientID": "netbird-cli",
|
||||
"Scope": "openid profile email offline_access",
|
||||
"RedirectURLs": ["http://localhost:53000/", "http://localhost:54000/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_dashboard_env() {
|
||||
cat <<EOF
|
||||
# Endpoints
|
||||
NETBIRD_MGMT_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
|
||||
NETBIRD_MGMT_GRPC_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
|
||||
# OIDC
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=$DEX_DASHBOARD_CLIENT_SECRET
|
||||
AUTH_AUTHORITY=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email offline_access
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
# SSL
|
||||
NGINX_SSL_PORT=443
|
||||
# Letsencrypt
|
||||
LETSENCRYPT_DOMAIN=none
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_relay_env() {
|
||||
cat <<EOF
|
||||
NB_LOG_LEVEL=info
|
||||
NB_LISTEN_ADDRESS=:80
|
||||
NB_EXPOSED_ADDRESS=$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT
|
||||
NB_AUTH_SECRET=$NETBIRD_RELAY_AUTH_SECRET
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_docker_compose() {
|
||||
cat <<EOF
|
||||
services:
|
||||
# Caddy reverse proxy
|
||||
caddy:
|
||||
image: caddy
|
||||
container_name: netbird-caddy
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
ports:
|
||||
- '443:443'
|
||||
- '443:443/udp'
|
||||
- '80:80'
|
||||
volumes:
|
||||
- netbird_caddy_data:/data
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Dex - identity provider
|
||||
dex:
|
||||
image: ghcr.io/dexidp/dex:v2.38.0
|
||||
container_name: netbird-dex
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
volumes:
|
||||
- ./dex.yaml:/etc/dex/config.docker.yaml:ro
|
||||
- netbird_dex_data:/var/dex
|
||||
command: ["dex", "serve", "/etc/dex/config.docker.yaml"]
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# UI dashboard
|
||||
dashboard:
|
||||
image: netbirdio/dashboard:latest
|
||||
container_name: netbird-dashboard
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
env_file:
|
||||
- ./dashboard.env
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Signal
|
||||
signal:
|
||||
image: netbirdio/signal:latest
|
||||
container_name: netbird-signal
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Relay
|
||||
relay:
|
||||
image: netbirdio/relay:latest
|
||||
container_name: netbird-relay
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
env_file:
|
||||
- ./relay.env
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Management
|
||||
management:
|
||||
image: netbirdio/management:latest
|
||||
container_name: netbird-management
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
volumes:
|
||||
- netbird_management:/var/lib/netbird
|
||||
- ./management.json:/etc/netbird/management.json
|
||||
command: [
|
||||
"--port", "80",
|
||||
"--log-file", "console",
|
||||
"--log-level", "info",
|
||||
"--disable-anonymous-metrics=false",
|
||||
"--single-account-mode-domain=netbird.selfhosted",
|
||||
"--dns-domain=netbird.selfhosted",
|
||||
"--idp-sign-key-refresh-enabled",
|
||||
]
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Coturn, AKA TURN server
|
||||
coturn:
|
||||
image: coturn/coturn
|
||||
container_name: netbird-coturn
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/turnserver.conf:ro
|
||||
network_mode: host
|
||||
command:
|
||||
- -c /etc/turnserver.conf
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
volumes:
|
||||
netbird_caddy_data:
|
||||
netbird_dex_data:
|
||||
netbird_management:
|
||||
|
||||
networks:
|
||||
netbird:
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
init_environment
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
|
||||
"github.com/netbirdio/netbird/formatter/hook"
|
||||
agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
"github.com/netbirdio/netbird/management/internals/server"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -112,6 +113,29 @@ var (
|
||||
mgmtSingleAccModeDomain = ""
|
||||
}
|
||||
|
||||
// Load the management-side LLM pricing defaults file: an
|
||||
// explicitly configured path is required to load (a typo must
|
||||
// fail startup — the operator believes those rates are live);
|
||||
// otherwise <datadir>/defaults_llm_pricing.yaml is probed and
|
||||
// may be absent (compiled-in defaults serve). A relative path
|
||||
// is resolved against the datadir so a bare filename lands
|
||||
// alongside the store. Either way the path stays watched: the
|
||||
// reloader picks up edits — and the file appearing later —
|
||||
// without a restart.
|
||||
pricingPath := config.AgentNetwork.PricingDefaultsFile
|
||||
pricingRequired := pricingPath != ""
|
||||
if !pricingRequired {
|
||||
pricingPath = agentnetworkpricing.DefaultFileName
|
||||
}
|
||||
if !filepath.IsAbs(pricingPath) {
|
||||
pricingPath = filepath.Join(config.Datadir, pricingPath)
|
||||
}
|
||||
log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, pricingRequired)
|
||||
if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil {
|
||||
return fmt.Errorf("load agent-network pricing defaults: %v", err)
|
||||
}
|
||||
agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
|
||||
|
||||
srv := newServer(&server.Config{
|
||||
NbConfig: config,
|
||||
DNSDomain: dnsDomain,
|
||||
|
||||
@@ -7,12 +7,28 @@ package catalog
|
||||
import "github.com/netbirdio/netbird/shared/management/http/api"
|
||||
|
||||
// Model is the in-memory representation of a catalog model.
|
||||
//
|
||||
// The three cache rates mirror the proxy cost meter's Entry semantics
|
||||
// (USD per 1k tokens; 0 = no rate configured, that bucket bills at
|
||||
// InputPer1k):
|
||||
// - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens
|
||||
// (a SUBSET of input tokens). Typically 0.1-0.5x input.
|
||||
// - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for
|
||||
// the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x
|
||||
// input.
|
||||
//
|
||||
// The catalog is the single default-pricing source: the agentnetwork
|
||||
// pricing package folds these models into per-surface tables that the
|
||||
// synthesizer ships to the proxy's cost_meter.
|
||||
type Model struct {
|
||||
ID string
|
||||
Label string
|
||||
InputPer1k float64
|
||||
OutputPer1k float64
|
||||
ContextWindow int
|
||||
ID string
|
||||
Label string
|
||||
InputPer1k float64
|
||||
OutputPer1k float64
|
||||
CachedInputPer1k float64
|
||||
CacheReadPer1k float64
|
||||
CacheCreationPer1k float64
|
||||
ContextWindow int
|
||||
}
|
||||
|
||||
// ProviderKind groups catalog entries for UI presentation. The split
|
||||
@@ -65,6 +81,17 @@ type Provider struct {
|
||||
// surface — the proxy middleware then falls back to URL sniffing
|
||||
// or skips request-side enrichment.
|
||||
ParserID string
|
||||
// PricingSurfaces names the cost-meter pricing surfaces this
|
||||
// provider's Models are priced under ("openai", "anthropic",
|
||||
// "bedrock" — the llm.Parser surface the request parser stamps as
|
||||
// llm.provider at billing time). NOT derivable from ParserID:
|
||||
// bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed)
|
||||
// yet price under "bedrock" / "anthropic", and kimi_api serves two
|
||||
// body shapes so it prices under both. Nil for gateway/custom
|
||||
// entries, which declare no models. Same (surface, model) pair
|
||||
// contributed by two providers must carry identical rates — the
|
||||
// pricing package's tests enforce that.
|
||||
PricingSurfaces []string
|
||||
// IdentityInjection, when non-nil, instructs the proxy to stamp
|
||||
// the caller's NetBird identity onto upstream requests under the
|
||||
// configured header names. Used for gateways like LiteLLM that
|
||||
@@ -219,6 +246,7 @@ var providers = []Provider{
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#10A37F",
|
||||
ParserID: "openai",
|
||||
PricingSurfaces: []string{"openai"},
|
||||
// Pricing + context windows cross-checked against LiteLLM's
|
||||
// model_prices_and_context_window.json. Notable corrections from
|
||||
// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
|
||||
@@ -226,20 +254,20 @@ var providers = []Provider{
|
||||
// family context windows split between 1.05M for full-size
|
||||
// models and 272K for mini/nano/codex variants.
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
|
||||
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
|
||||
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000},
|
||||
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000},
|
||||
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
|
||||
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576},
|
||||
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
|
||||
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
|
||||
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
|
||||
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
|
||||
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000},
|
||||
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000},
|
||||
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
|
||||
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576},
|
||||
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
|
||||
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
|
||||
{ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000},
|
||||
{ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
|
||||
{ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191},
|
||||
@@ -257,6 +285,7 @@ var providers = []Provider{
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#D97757",
|
||||
ParserID: "anthropic",
|
||||
PricingSurfaces: []string{"anthropic"},
|
||||
// Per Anthropic's current model lineup. Pricing in USD per 1k
|
||||
// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
|
||||
// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
|
||||
@@ -267,14 +296,14 @@ var providers = []Provider{
|
||||
// account to be on >= 30-day data retention or all requests
|
||||
// 400.
|
||||
Models: []Model{
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
|
||||
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
|
||||
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
|
||||
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
|
||||
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,18 +317,19 @@ var providers = []Provider{
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#0078D4",
|
||||
ParserID: "openai",
|
||||
PricingSurfaces: []string{"openai"},
|
||||
// Mirrors openai_api pricing — Azure resells OpenAI models at the
|
||||
// same per-token rates, just under different deployment names.
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
|
||||
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
|
||||
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
|
||||
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
|
||||
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
|
||||
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
|
||||
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
|
||||
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
|
||||
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
|
||||
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
|
||||
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
|
||||
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
|
||||
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
|
||||
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
|
||||
{ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
|
||||
},
|
||||
},
|
||||
@@ -313,6 +343,9 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#FF9900",
|
||||
// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
|
||||
// the request parser meters these under the "bedrock" surface.
|
||||
PricingSurfaces: []string{"bedrock"},
|
||||
// Anthropic models on Bedrock take the anthropic.* prefix and
|
||||
// follow the same lineup / pricing as the first-party Anthropic
|
||||
// catalog entry above. claude-3-7-sonnet and claude-3-5-haiku
|
||||
@@ -322,13 +355,13 @@ var providers = []Provider{
|
||||
// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
|
||||
// per-region Llama 3 entries; standalone 3.3 not yet listed.
|
||||
Models: []Model{
|
||||
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
|
||||
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
|
||||
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
|
||||
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
|
||||
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
|
||||
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
|
||||
{ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000},
|
||||
{ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000},
|
||||
{ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000},
|
||||
@@ -358,6 +391,10 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#4285F4",
|
||||
// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
|
||||
// Anthropic-on-Vertex requests are metered under the "anthropic"
|
||||
// surface with the bare, unversioned model id.
|
||||
PricingSurfaces: []string{"anthropic"},
|
||||
// Vertex carries the model in the URL path and authenticates with a
|
||||
// service-account-minted OAuth token (api_key = "keyfile::<base64 SA>").
|
||||
// Only Anthropic-on-Vertex is metered today: the request parser maps the
|
||||
@@ -369,14 +406,14 @@ var providers = []Provider{
|
||||
// exists — the router denies unmeterable publishers rather than forward
|
||||
// them uncounted.
|
||||
Models: []Model{
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
|
||||
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
|
||||
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
|
||||
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
|
||||
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -390,6 +427,7 @@ var providers = []Provider{
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#FF7000",
|
||||
ParserID: "openai",
|
||||
PricingSurfaces: []string{"openai"},
|
||||
// Pricing + context windows cross-checked against LiteLLM. Key
|
||||
// gotchas the marketing page hides:
|
||||
// - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2),
|
||||
@@ -448,6 +486,10 @@ var providers = []Provider{
|
||||
// model id "k3") is account-bound seat licensing rather than a
|
||||
// meterable platform key, so it's deliberately not the default.
|
||||
ParserID: "",
|
||||
// Both body shapes are metered: /v1/chat/completions under
|
||||
// "openai", /anthropic/v1/messages under "anthropic" — so the
|
||||
// K3 entry is priced on both surfaces.
|
||||
PricingSurfaces: []string{"openai", "anthropic"},
|
||||
// Pricing per Moonshot's platform rates at K3 launch (July 2026):
|
||||
// $3/$15 per MTok with $0.30 cached input, flat across the 1M-token
|
||||
// window. kimi-k3 is the ONLY model the platform serves newer
|
||||
@@ -458,7 +500,14 @@ var providers = []Provider{
|
||||
// The consumer app's "K3 Swarm Max" mode is not an API SKU, so it
|
||||
// doesn't appear here.
|
||||
Models: []Model{
|
||||
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
|
||||
// Carries both cache shapes: Moonshot reports cache hits
|
||||
// OpenAI-style on /v1/chat/completions (CachedInputPer1k)
|
||||
// and Anthropic-style on /anthropic/v1/messages
|
||||
// (CacheReadPer1k) — $0.30/MTok either way. Each surface's
|
||||
// cost formula reads only its own field, so the superset
|
||||
// entry prices both endpoints correctly. No cache-creation
|
||||
// rate published; writes bill at the input rate.
|
||||
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -758,13 +807,28 @@ func IsBedrockPathStyle(providerID string) bool {
|
||||
func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
|
||||
models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models))
|
||||
for _, m := range p.Models {
|
||||
models = append(models, api.AgentNetworkCatalogModel{
|
||||
am := api.AgentNetworkCatalogModel{
|
||||
Id: m.ID,
|
||||
Label: m.Label,
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
ContextWindow: m.ContextWindow,
|
||||
})
|
||||
}
|
||||
// Cache rates are emitted only when configured so the dashboard
|
||||
// can prefill them; 0 stays off the wire (absent = no rate).
|
||||
if m.CachedInputPer1k > 0 {
|
||||
v := m.CachedInputPer1k
|
||||
am.CachedInputPer1k = &v
|
||||
}
|
||||
if m.CacheReadPer1k > 0 {
|
||||
v := m.CacheReadPer1k
|
||||
am.CacheReadPer1k = &v
|
||||
}
|
||||
if m.CacheCreationPer1k > 0 {
|
||||
v := m.CacheCreationPer1k
|
||||
am.CacheCreationPer1k = &v
|
||||
}
|
||||
models = append(models, am)
|
||||
}
|
||||
kind := api.AgentNetworkCatalogProviderKindProvider
|
||||
switch p.Kind {
|
||||
@@ -784,6 +848,10 @@ func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
|
||||
BrandColor: p.BrandColor,
|
||||
Models: models,
|
||||
}
|
||||
if len(p.PricingSurfaces) > 0 {
|
||||
surfaces := append([]string(nil), p.PricingSurfaces...)
|
||||
resp.PricingSurfaces = &surfaces
|
||||
}
|
||||
if len(p.ExtraHeaders) > 0 {
|
||||
extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders))
|
||||
for _, h := range p.ExtraHeaders {
|
||||
|
||||
@@ -7,6 +7,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
@@ -52,11 +54,45 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
|
||||
entries := catalog.All()
|
||||
out := make([]api.AgentNetworkCatalogProvider, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.ToAPIResponse())
|
||||
resp := e.ToAPIResponse()
|
||||
applyDefaultPricing(e, &resp)
|
||||
out = append(out, resp)
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
// applyDefaultPricing overwrites the catalog response's model rates with
|
||||
// the LIVE default pricing table, which may differ from the compiled-in
|
||||
// catalog rates when the operator provides a defaults_llm_pricing.yaml.
|
||||
// This keeps the dashboard's model-row prefill identical to what the
|
||||
// proxy will actually bill — the same table the synthesizer ships.
|
||||
func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) {
|
||||
if len(cp.PricingSurfaces) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range resp.Models {
|
||||
m := &resp.Models[i]
|
||||
e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m.InputPer1k = e.InputPer1k
|
||||
m.OutputPer1k = e.OutputPer1k
|
||||
m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k)
|
||||
m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k)
|
||||
m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k)
|
||||
}
|
||||
}
|
||||
|
||||
// positiveRatePtr renders a cache rate for the API: absent (nil) when
|
||||
// unset, matching the catalog response convention.
|
||||
func positiveRatePtr(v float64) *float64 {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -213,5 +249,38 @@ func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
|
||||
if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
|
||||
return status.Errorf(status.InvalidArgument, "api_key is required")
|
||||
}
|
||||
if req.Models != nil {
|
||||
for i, m := range *req.Models {
|
||||
if err := validateModel(i, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModel is the single ingress guard for operator-entered pricing:
|
||||
// these rates are synthesized into the proxy's cost_meter config verbatim,
|
||||
// and a negative or non-finite rate there would poison every cost the
|
||||
// proxy records, so reject at the API boundary.
|
||||
func validateModel(i int, m api.AgentNetworkProviderModel) error {
|
||||
if strings.TrimSpace(m.Id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i)
|
||||
}
|
||||
rates := map[string]*float64{
|
||||
"input_per_1k": &m.InputPer1k,
|
||||
"output_per_1k": &m.OutputPer1k,
|
||||
"cached_input_per_1k": m.CachedInputPer1k,
|
||||
"cache_read_per_1k": m.CacheReadPer1k,
|
||||
"cache_creation_per_1k": m.CacheCreationPer1k,
|
||||
}
|
||||
for field, v := range rates {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) {
|
||||
return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
// TestValidate_ModelRates guards the single ingress point for operator-entered
|
||||
// pricing. These rates flow verbatim into the proxy's cost_meter config at
|
||||
// synthesis time; the proxy treats a bad rate as a chain-build failure, so
|
||||
// rejecting here is what keeps an account's gateway from going down.
|
||||
func TestValidate_ModelRates(t *testing.T) {
|
||||
base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest {
|
||||
key := "sk-test"
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: &key,
|
||||
Models: &models,
|
||||
}
|
||||
}
|
||||
|
||||
valid := api.AgentNetworkProviderModel{
|
||||
Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01,
|
||||
CachedInputPer1k: f(0.00125),
|
||||
}
|
||||
require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass")
|
||||
|
||||
zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0}
|
||||
require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)")
|
||||
|
||||
cases := map[string]api.AgentNetworkProviderModel{
|
||||
"empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
"negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002},
|
||||
"negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002},
|
||||
"NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002},
|
||||
"Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)},
|
||||
"negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)},
|
||||
"NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())},
|
||||
"Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))},
|
||||
}
|
||||
for name, m := range cases {
|
||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
156
management/internals/modules/agentnetwork/pricing/defaults.go
Normal file
156
management/internals/modules/agentnetwork/pricing/defaults.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package pricing builds the default LLM pricing table the synthesizer
|
||||
// ships to the proxy's cost_meter middleware. The catalog is the single
|
||||
// source of default rates: every catalog provider's models are folded
|
||||
// into the pricing surfaces the provider declares (PricingSurfaces),
|
||||
// then a small supplemental list adds priced-but-not-operator-selectable
|
||||
// entries. Management is the sole pricing authority — the proxy carries
|
||||
// no embedded price list and bills exclusively from the table it is
|
||||
// sent.
|
||||
//go:generate go run gen.go
|
||||
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
)
|
||||
|
||||
// Entry is a single model's pricing in USD per 1k tokens. This struct IS
|
||||
// the wire shape: the synthesizer marshals it verbatim into cost_meter's
|
||||
// ConfigJSON, and the proxy unmarshals the same field names.
|
||||
//
|
||||
// A zero rate means "no rate configured" — the proxy bills that cache
|
||||
// bucket at InputPer1k (identical semantics to the retired proxy-embedded
|
||||
// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are
|
||||
// a subset of input); CacheReadPer1k / CacheCreationPer1k are the
|
||||
// Anthropic shape (additive buckets).
|
||||
type Entry struct {
|
||||
InputPer1k float64 `json:"input_per_1k"`
|
||||
OutputPer1k float64 `json:"output_per_1k"`
|
||||
CachedInputPer1k float64 `json:"cached_input_per_1k,omitempty"`
|
||||
CacheReadPer1k float64 `json:"cache_read_per_1k,omitempty"`
|
||||
CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"`
|
||||
}
|
||||
|
||||
// supplementalDefaults are (surface, model) entries that are priced but
|
||||
// deliberately not operator-selectable in the catalog. Each carries a
|
||||
// reason; when one of these models joins a catalog lineup, delete the
|
||||
// row here — the collision test fails loudly if the rates ever disagree.
|
||||
var supplementalDefaults = map[string]map[string]Entry{
|
||||
"openai": {
|
||||
// GPT-5 (2025) family — kept for gateway requests using the
|
||||
// unsuffixed ids; the dashboard offers only the 5.x lineup.
|
||||
"gpt-5": {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125},
|
||||
"gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025},
|
||||
"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
|
||||
},
|
||||
"anthropic": {
|
||||
// claude-opus-5 is not yet in the catalog lineup but gateway /
|
||||
// grandfathered traffic uses it; priced so it isn't skipped.
|
||||
"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
|
||||
// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
|
||||
// configure against Moonshot's Anthropic-compatible endpoint;
|
||||
// priced identically to kimi-k3 so those requests aren't skipped.
|
||||
"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
|
||||
},
|
||||
"bedrock": {
|
||||
"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
compiledOnce sync.Once
|
||||
compiledTable map[string]map[string]Entry
|
||||
// mergedTable holds the current live table when a pricing defaults
|
||||
// file is loaded: the file merged entry-whole over the compiled-in
|
||||
// base. Nil while no file is loaded (or after the file is removed),
|
||||
// in which case the compiled-in table serves. Swapped atomically by
|
||||
// the file loader/reloader; readers never block.
|
||||
mergedTable atomic.Pointer[map[string]map[string]Entry]
|
||||
)
|
||||
|
||||
// DefaultTable returns the current default pricing table keyed
|
||||
// surface -> model -> Entry: the management-side defaults file (see
|
||||
// LoadFile / StartReloader) when one is loaded, merged over the
|
||||
// compiled-in catalog table, which alone serves as the fallback when no
|
||||
// file exists. The snapshot may change between calls as the file is
|
||||
// re-read — consumers (the synthesizer on every reconcile, the catalog
|
||||
// endpoint on every request) pick up fresh rates automatically. Callers
|
||||
// must not mutate the returned maps.
|
||||
func DefaultTable() map[string]map[string]Entry {
|
||||
if t := mergedTable.Load(); t != nil {
|
||||
return *t
|
||||
}
|
||||
return compiledBase()
|
||||
}
|
||||
|
||||
// compiledBase returns the compiled-in table (catalog + supplementals),
|
||||
// built once.
|
||||
func compiledBase() map[string]map[string]Entry {
|
||||
compiledOnce.Do(func() {
|
||||
compiledTable = buildDefaultTable()
|
||||
})
|
||||
return compiledTable
|
||||
}
|
||||
|
||||
func buildDefaultTable() map[string]map[string]Entry {
|
||||
out := make(map[string]map[string]Entry)
|
||||
for _, p := range catalog.All() {
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
inner, ok := out[surface]
|
||||
if !ok {
|
||||
inner = make(map[string]Entry)
|
||||
out[surface] = inner
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
// First writer wins; providers contributing the same
|
||||
// (surface, model) must agree on rates — enforced by
|
||||
// TestDefaultTable_NoConflictingContributions.
|
||||
if _, dup := inner[m.ID]; dup {
|
||||
continue
|
||||
}
|
||||
inner[m.ID] = entryFromCatalogModel(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
for surface, models := range supplementalDefaults {
|
||||
inner, ok := out[surface]
|
||||
if !ok {
|
||||
inner = make(map[string]Entry)
|
||||
out[surface] = inner
|
||||
}
|
||||
for id, e := range models {
|
||||
if _, dup := inner[id]; dup {
|
||||
continue
|
||||
}
|
||||
inner[id] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entryFromCatalogModel(m catalog.Model) Entry {
|
||||
return Entry{
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
CachedInputPer1k: m.CachedInputPer1k,
|
||||
CacheReadPer1k: m.CacheReadPer1k,
|
||||
CacheCreationPer1k: m.CacheCreationPer1k,
|
||||
}
|
||||
}
|
||||
|
||||
// LookupDefault returns the default entry for model on the first of the
|
||||
// given surfaces that prices it. Used by the synthesizer to seed a
|
||||
// per-provider entry with default cache rates before overlaying the
|
||||
// operator's stored prices.
|
||||
func LookupDefault(surfaces []string, model string) (Entry, bool) {
|
||||
table := DefaultTable()
|
||||
for _, s := range surfaces {
|
||||
if e, ok := table[s][model]; ok {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return Entry{}, false
|
||||
}
|
||||
@@ -1,83 +1,176 @@
|
||||
# Embedded default pricing for llm_observability. Compiled into the proxy
|
||||
# binary via go:embed in pricing.go; cost annotation works out of the box
|
||||
# without any operator action.
|
||||
# Default LLM pricing used by NetBird's Agent Network cost metering.
|
||||
# GENERATED from the management catalog — do not edit this file in the
|
||||
# repository; regenerate with:
|
||||
#
|
||||
# Operators override entries by dropping a pricing.yaml into --plugin-data-dir
|
||||
# (or whichever basename is given via params.pricing_path). The override file
|
||||
# only needs entries the operator wants to change; missing entries fall
|
||||
# through to these defaults.
|
||||
# go generate ./management/internals/modules/agentnetwork/pricing
|
||||
#
|
||||
# Values are USD per 1_000 tokens. Public list prices drift; ship a fresh
|
||||
# binary or override individual entries via the override file as needed.
|
||||
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
|
||||
# any path configured via management.json:
|
||||
#
|
||||
# Optional cache fields:
|
||||
# cached_input_per_1k OpenAI: rate for prompt_tokens_details.cached_tokens
|
||||
# (a SUBSET of prompt_tokens). Typically 0.5x input.
|
||||
# Absent → cached portion bills at input_per_1k.
|
||||
# cache_read_per_1k Anthropic: rate for cache_read_input_tokens
|
||||
# (ADDITIVE to input_tokens). Typically 0.1x input.
|
||||
# Absent → cache reads bill at input_per_1k.
|
||||
# cache_creation_per_1k Anthropic: rate for cache_creation_input_tokens
|
||||
# (ADDITIVE to input_tokens). Typically 1.25x input.
|
||||
# Absent → cache writes bill at input_per_1k.
|
||||
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
|
||||
#
|
||||
# ) and adjust the entries you want to change. Management re-reads the
|
||||
# file periodically (mtime poll, every minute): the live table feeds the
|
||||
# proxies' cost metering and the dashboard's model-price prefill, so
|
||||
# edits apply without a restart. Your file only needs the entries you
|
||||
# want to change — but each entry REPLACES the built-in entry for that
|
||||
# surface+model whole, so repeat the cache rates you want to keep.
|
||||
# Unknown fields and negative or non-finite rates are rejected: at
|
||||
# startup that fails boot (for an explicitly configured path); at
|
||||
# runtime the previous table is kept and a warning is logged. Deleting
|
||||
# the file reverts to the built-in defaults below.
|
||||
#
|
||||
# Top-level keys are pricing surfaces — the parser shape requests are
|
||||
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
|
||||
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
|
||||
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
|
||||
# the normalized id the proxy meters (version/region suffixes stripped).
|
||||
#
|
||||
# Values are USD per 1_000 tokens. Optional cache fields:
|
||||
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
|
||||
# (a SUBSET of input tokens). Absent -> cached
|
||||
# portion bills at input_per_1k.
|
||||
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
|
||||
# (ADDITIVE to input). Absent -> input rate.
|
||||
# cache_creation_per_1k Anthropic shape: rate for cache_creation
|
||||
# tokens (ADDITIVE to input). Absent -> input
|
||||
# rate.
|
||||
|
||||
anthropic:
|
||||
claude-fable-5:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.05
|
||||
cache_read_per_1k: 0.001
|
||||
cache_creation_per_1k: 0.0125
|
||||
claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.0003
|
||||
cache_read_per_1k: 0.0003
|
||||
"kimi-k3[1m]":
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
|
||||
bedrock:
|
||||
amazon.nova-2-lite:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0025
|
||||
amazon.nova-lite:
|
||||
input_per_1k: 0.00006
|
||||
output_per_1k: 0.00024
|
||||
amazon.nova-micro:
|
||||
input_per_1k: 0.000035
|
||||
output_per_1k: 0.00014
|
||||
amazon.nova-pro:
|
||||
input_per_1k: 0.0008
|
||||
output_per_1k: 0.0032
|
||||
anthropic.claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
anthropic.claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
anthropic.claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
meta.llama3-3-70b-instruct:
|
||||
input_per_1k: 0.00072
|
||||
output_per_1k: 0.00072
|
||||
|
||||
openai:
|
||||
# OpenAI + OpenAI-compatible providers (openai_api, azure_openai_api,
|
||||
# mistral_api, and the openai-parser gateways) all emit llm.provider="openai",
|
||||
# so their models are priced here. Kept in sync with the management catalog;
|
||||
# rates cross-checked against LiteLLM model_prices_and_context_window.json.
|
||||
|
||||
# GPT-5.x family — cache reads 10% of input (0.1x).
|
||||
gpt-5.5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.03
|
||||
cached_input_per_1k: 0.0005
|
||||
gpt-5.5-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
gpt-5.4:
|
||||
input_per_1k: 0.0025
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.00025
|
||||
gpt-5.4-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
gpt-5.4-mini:
|
||||
input_per_1k: 0.00075
|
||||
output_per_1k: 0.0045
|
||||
cached_input_per_1k: 0.000075
|
||||
gpt-5.4-nano:
|
||||
input_per_1k: 0.0002
|
||||
output_per_1k: 0.00125
|
||||
cached_input_per_1k: 0.00002
|
||||
gpt-5.3-codex:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
gpt-5.3-chat-latest:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
# GPT-5 (2025) family — kept for gateway requests using the unsuffixed ids.
|
||||
gpt-5:
|
||||
input_per_1k: 0.00125
|
||||
output_per_1k: 0.01
|
||||
cached_input_per_1k: 0.000125
|
||||
gpt-5-mini:
|
||||
input_per_1k: 0.00025
|
||||
codestral-2508:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0009
|
||||
codestral-latest:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.003
|
||||
devstral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.002
|
||||
cached_input_per_1k: 0.000025
|
||||
gpt-5-nano:
|
||||
input_per_1k: 0.00005
|
||||
output_per_1k: 0.0004
|
||||
cached_input_per_1k: 0.000005
|
||||
o4-mini:
|
||||
input_per_1k: 0.0011
|
||||
output_per_1k: 0.0044
|
||||
cached_input_per_1k: 0.000275
|
||||
# GPT-4.1 family — cache reads 25% of input.
|
||||
devstral-small-latest:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0003
|
||||
gpt-3.5-turbo:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
gpt-35-turbo:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
gpt-4-turbo:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.03
|
||||
gpt-4.1:
|
||||
input_per_1k: 0.002
|
||||
output_per_1k: 0.008
|
||||
@@ -90,7 +183,6 @@ openai:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0004
|
||||
cached_input_per_1k: 0.000025
|
||||
# GPT-4o family — cache reads 50% of input (0.5x).
|
||||
gpt-4o:
|
||||
input_per_1k: 0.0025
|
||||
output_per_1k: 0.01
|
||||
@@ -99,200 +191,92 @@ openai:
|
||||
input_per_1k: 0.00015
|
||||
output_per_1k: 0.0006
|
||||
cached_input_per_1k: 0.000075
|
||||
# Older GPT — no prompt caching.
|
||||
gpt-4-turbo:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.03
|
||||
gpt-3.5-turbo:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
gpt-35-turbo: # Azure deployment alias of gpt-3.5-turbo
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
# Embeddings — no caching, no output tokens.
|
||||
text-embedding-3-large:
|
||||
input_per_1k: 0.00013
|
||||
output_per_1k: 0
|
||||
text-embedding-3-small:
|
||||
input_per_1k: 0.00002
|
||||
output_per_1k: 0
|
||||
|
||||
# Mistral (mistral_api) — routed via the openai parser; no prompt caching.
|
||||
mistral-large-latest:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
mistral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
gpt-5:
|
||||
input_per_1k: 0.00125
|
||||
output_per_1k: 0.01
|
||||
cached_input_per_1k: 0.000125
|
||||
gpt-5-mini:
|
||||
input_per_1k: 0.00025
|
||||
output_per_1k: 0.002
|
||||
mistral-medium-3-5:
|
||||
input_per_1k: 0.0015
|
||||
output_per_1k: 0.0075
|
||||
mistral-small-latest:
|
||||
input_per_1k: 0.00006
|
||||
output_per_1k: 0.00018
|
||||
cached_input_per_1k: 0.000025
|
||||
gpt-5-nano:
|
||||
input_per_1k: 0.00005
|
||||
output_per_1k: 0.0004
|
||||
cached_input_per_1k: 0.000005
|
||||
gpt-5.3-chat-latest:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
gpt-5.3-codex:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
gpt-5.4:
|
||||
input_per_1k: 0.0025
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.00025
|
||||
gpt-5.4-mini:
|
||||
input_per_1k: 0.00075
|
||||
output_per_1k: 0.0045
|
||||
cached_input_per_1k: 0.000075
|
||||
gpt-5.4-nano:
|
||||
input_per_1k: 0.0002
|
||||
output_per_1k: 0.00125
|
||||
cached_input_per_1k: 0.00002
|
||||
gpt-5.4-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
gpt-5.5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.03
|
||||
cached_input_per_1k: 0.0005
|
||||
gpt-5.5-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.0003
|
||||
cache_read_per_1k: 0.0003
|
||||
magistral-medium-latest:
|
||||
input_per_1k: 0.002
|
||||
output_per_1k: 0.005
|
||||
magistral-small-latest:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
devstral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.002
|
||||
devstral-small-latest:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0003
|
||||
codestral-2508:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0009
|
||||
codestral-latest:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.003
|
||||
ministral-3-14b-2512:
|
||||
input_per_1k: 0.0002
|
||||
output_per_1k: 0.0002
|
||||
ministral-8b-latest:
|
||||
input_per_1k: 0.00015
|
||||
output_per_1k: 0.00015
|
||||
ministral-3-3b-2512:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0001
|
||||
ministral-8b-latest:
|
||||
input_per_1k: 0.00015
|
||||
output_per_1k: 0.00015
|
||||
mistral-embed:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0
|
||||
|
||||
# Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot
|
||||
# reports cache hits OpenAI-style when present; cached input is 10% of
|
||||
# input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform
|
||||
# serves newer accounts (K2-era ids and kimi-latest 404), matching the
|
||||
# management catalog.
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.0003
|
||||
|
||||
anthropic:
|
||||
# Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input.
|
||||
# Pricing source: Anthropic's current published rates per million tokens,
|
||||
# divided by 1000 for the per-1k figures stored here.
|
||||
claude-fable-5:
|
||||
input_per_1k: 0.010
|
||||
output_per_1k: 0.050
|
||||
cache_read_per_1k: 0.001
|
||||
cache_creation_per_1k: 0.0125
|
||||
claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
|
||||
# Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint
|
||||
# (/anthropic/v1/messages — the official Claude Code setup). Same rates
|
||||
# as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some
|
||||
# Claude Code guides set for the 1M-context alias; priced identically so
|
||||
# cost metering doesn't silently skip those requests.
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
"kimi-k3[1m]":
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
|
||||
bedrock:
|
||||
# AWS Bedrock model ids, normalised by the request parser (cross-region
|
||||
# inference-profile prefix + version/throughput suffix stripped), e.g.
|
||||
# eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5.
|
||||
# Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input,
|
||||
# write ≈1.25x input); Nova / Llama report no cache, so cost is input+output.
|
||||
anthropic.claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
anthropic.claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
meta.llama3-3-70b-instruct:
|
||||
input_per_1k: 0.00072
|
||||
output_per_1k: 0.00072
|
||||
amazon.nova-2-lite:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0025
|
||||
amazon.nova-pro:
|
||||
input_per_1k: 0.0008
|
||||
output_per_1k: 0.0032
|
||||
amazon.nova-lite:
|
||||
mistral-large-latest:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
mistral-medium-3-5:
|
||||
input_per_1k: 0.0015
|
||||
output_per_1k: 0.0075
|
||||
mistral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.002
|
||||
mistral-small-latest:
|
||||
input_per_1k: 0.00006
|
||||
output_per_1k: 0.00024
|
||||
amazon.nova-micro:
|
||||
input_per_1k: 0.000035
|
||||
output_per_1k: 0.00014
|
||||
output_per_1k: 0.00018
|
||||
o4-mini:
|
||||
input_per_1k: 0.0011
|
||||
output_per_1k: 0.0044
|
||||
cached_input_per_1k: 0.000275
|
||||
text-embedding-3-large:
|
||||
input_per_1k: 0.00013
|
||||
output_per_1k: 0
|
||||
text-embedding-3-small:
|
||||
input_per_1k: 0.00002
|
||||
output_per_1k: 0
|
||||
@@ -0,0 +1,148 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
)
|
||||
|
||||
// TestDefaultTable_CoversEveryCatalogModel replaces the proxy's old
|
||||
// hand-maintained coverage list: because the table is built FROM the
|
||||
// catalog, drift is impossible by construction — this test guards the
|
||||
// fold itself (every catalog model of every surfaced provider resolves,
|
||||
// with exactly the catalog's rates).
|
||||
func TestDefaultTable_CoversEveryCatalogModel(t *testing.T) {
|
||||
table := DefaultTable()
|
||||
for _, p := range catalog.All() {
|
||||
if len(p.PricingSurfaces) == 0 {
|
||||
assert.Empty(t, p.Models, "catalog entry %s declares models but no pricing surfaces — those models would never be priced", p.ID)
|
||||
continue
|
||||
}
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
byModel, ok := table[surface]
|
||||
require.True(t, ok, "surface %q (provider %s) missing from default table", surface, p.ID)
|
||||
for _, m := range p.Models {
|
||||
e, ok := byModel[m.ID]
|
||||
require.True(t, ok, "%s/%s (provider %s) missing from default table", surface, m.ID, p.ID)
|
||||
assert.Equal(t, m.InputPer1k, e.InputPer1k, "%s/%s input rate", surface, m.ID)
|
||||
assert.Equal(t, m.OutputPer1k, e.OutputPer1k, "%s/%s output rate", surface, m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_NoConflictingContributions enforces the collision rule
|
||||
// documented on catalog.Provider.PricingSurfaces: when two catalog
|
||||
// providers contribute the same (surface, model) pair — azure/vertex
|
||||
// mirroring openai/anthropic, kimi on both surfaces — their rates must be
|
||||
// identical, because the surface-keyed table can only hold one entry.
|
||||
// If a provider ever diverges (e.g. Azure reprices a model), this fails
|
||||
// and the divergence must move to per-provider-record pricing.
|
||||
func TestDefaultTable_NoConflictingContributions(t *testing.T) {
|
||||
type contribution struct {
|
||||
providerID string
|
||||
entry Entry
|
||||
}
|
||||
seen := map[string]map[string]contribution{}
|
||||
for _, p := range catalog.All() {
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
if seen[surface] == nil {
|
||||
seen[surface] = map[string]contribution{}
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
e := entryFromCatalogModel(m)
|
||||
if prev, dup := seen[surface][m.ID]; dup {
|
||||
assert.Equal(t, prev.entry, e,
|
||||
"%s/%s: %s and %s contribute different rates", surface, m.ID, prev.providerID, p.ID)
|
||||
continue
|
||||
}
|
||||
seen[surface][m.ID] = contribution{providerID: p.ID, entry: e}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supplemental entries must never shadow a catalog-contributed model —
|
||||
// they exist precisely because the catalog does NOT list them.
|
||||
for surface, models := range supplementalDefaults {
|
||||
for id := range models {
|
||||
_, fromCatalog := seen[surface][id]
|
||||
assert.False(t, fromCatalog, "supplemental %s/%s is now in the catalog — delete the supplemental row", surface, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_AllRatesFiniteNonNegative mirrors the proxy-side
|
||||
// NewTable validation so a bad catalog edit is caught here, at unit-test
|
||||
// time, rather than as a chain-build failure on every proxy.
|
||||
func TestDefaultTable_AllRatesFiniteNonNegative(t *testing.T) {
|
||||
for surface, models := range DefaultTable() {
|
||||
for id, e := range models {
|
||||
for field, v := range map[string]float64{
|
||||
"input": e.InputPer1k,
|
||||
"output": e.OutputPer1k,
|
||||
"cached_input": e.CachedInputPer1k,
|
||||
"cache_read": e.CacheReadPer1k,
|
||||
"cache_creation": e.CacheCreationPer1k,
|
||||
} {
|
||||
assert.False(t, v < 0 || math.IsNaN(v) || math.IsInf(v, 0),
|
||||
"%s/%s: %s rate %v must be finite and non-negative", surface, id, field, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_PinnedRates pins rates that previously drifted or are
|
||||
// easy to mis-enter (carried over from the proxy's retired
|
||||
// defaults_coverage_test), plus the supplemental entries.
|
||||
func TestDefaultTable_PinnedRates(t *testing.T) {
|
||||
table := DefaultTable()
|
||||
|
||||
gpt54 := table["openai"]["gpt-5.4"]
|
||||
assert.InDelta(t, 0.0025, gpt54.InputPer1k, 1e-9, "gpt-5.4 input")
|
||||
assert.InDelta(t, 0.015, gpt54.OutputPer1k, 1e-9, "gpt-5.4 output")
|
||||
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "gpt-5.4 cached input")
|
||||
|
||||
sonnet := table["bedrock"]["anthropic.claude-sonnet-4-5"]
|
||||
assert.InDelta(t, 0.003, sonnet.InputPer1k, 1e-9, "bedrock sonnet-4-5 input")
|
||||
assert.InDelta(t, 0.015, sonnet.OutputPer1k, 1e-9, "bedrock sonnet-4-5 output")
|
||||
assert.InDelta(t, 0.0003, sonnet.CacheReadPer1k, 1e-9, "bedrock sonnet-4-5 cache read")
|
||||
assert.InDelta(t, 0.00375, sonnet.CacheCreationPer1k, 1e-9, "bedrock sonnet-4-5 cache creation")
|
||||
|
||||
// Vertex Claude prices under "anthropic" with the bare id.
|
||||
fable := table["anthropic"]["claude-fable-5"]
|
||||
assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
|
||||
assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
|
||||
|
||||
// Supplementals present on their surfaces.
|
||||
for surface, ids := range map[string][]string{
|
||||
"openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
|
||||
"anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"},
|
||||
"bedrock": {"anthropic.claude-opus-5"},
|
||||
} {
|
||||
for _, id := range ids {
|
||||
_, ok := table[surface][id]
|
||||
assert.True(t, ok, "%s/%s must be priced", surface, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Embeddings bill input-only — output stays zero.
|
||||
emb := table["openai"]["text-embedding-3-large"]
|
||||
assert.Zero(t, emb.OutputPer1k, "embedding output rate must be zero")
|
||||
assert.Positive(t, emb.InputPer1k, "embedding input rate must be set")
|
||||
}
|
||||
|
||||
func TestLookupDefault_SurfaceOrder(t *testing.T) {
|
||||
// kimi-k3 exists on both surfaces; first surface in the slice wins.
|
||||
e, ok := LookupDefault([]string{"openai", "anthropic"}, "kimi-k3")
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 0.003, e.InputPer1k, 1e-9)
|
||||
|
||||
_, ok = LookupDefault([]string{"bedrock"}, "gpt-4o")
|
||||
assert.False(t, ok, "gpt-4o is not a bedrock model")
|
||||
|
||||
_, ok = LookupDefault(nil, "gpt-4o")
|
||||
assert.False(t, ok, "no surfaces, no match")
|
||||
}
|
||||
109
management/internals/modules/agentnetwork/pricing/exampleyaml.go
Normal file
109
management/internals/modules/agentnetwork/pricing/exampleyaml.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// MarshalDefaultsYAML renders the built-in default pricing table (catalog
|
||||
// + supplementals, WITHOUT any operator override) as the YAML schema
|
||||
// LoadOverrideFile consumes. It backs the generated
|
||||
// defaults_llm_pricing.example.yaml so operators start from a file that
|
||||
// matches the compiled-in rates exactly; a golden test keeps the two in
|
||||
// sync. Output is deterministic (sorted surfaces and models).
|
||||
func MarshalDefaultsYAML() []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(exampleHeader)
|
||||
|
||||
table := buildDefaultTable()
|
||||
surfaces := make([]string, 0, len(table))
|
||||
for s := range table {
|
||||
surfaces = append(surfaces, s)
|
||||
}
|
||||
sort.Strings(surfaces)
|
||||
|
||||
for _, surface := range surfaces {
|
||||
fmt.Fprintf(&b, "\n%s:\n", surface)
|
||||
models := table[surface]
|
||||
ids := make([]string, 0, len(models))
|
||||
for id := range models {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
e := models[id]
|
||||
fmt.Fprintf(&b, " %s:\n", yamlKey(id))
|
||||
fmt.Fprintf(&b, " input_per_1k: %s\n", rate(e.InputPer1k))
|
||||
fmt.Fprintf(&b, " output_per_1k: %s\n", rate(e.OutputPer1k))
|
||||
if e.CachedInputPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cached_input_per_1k: %s\n", rate(e.CachedInputPer1k))
|
||||
}
|
||||
if e.CacheReadPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cache_read_per_1k: %s\n", rate(e.CacheReadPer1k))
|
||||
}
|
||||
if e.CacheCreationPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cache_creation_per_1k: %s\n", rate(e.CacheCreationPer1k))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// rate renders a USD-per-1k rate without float noise ("0.00015", not
|
||||
// "0.000150000000...").
|
||||
func rate(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// yamlKey quotes model ids that YAML would otherwise misparse (e.g.
|
||||
// "kimi-k3[1m]" starts a flow sequence unquoted).
|
||||
func yamlKey(id string) string {
|
||||
for _, r := range id {
|
||||
switch r {
|
||||
case '[', ']', '{', '}', ':', '#', ',', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`':
|
||||
return strconv.Quote(id)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const exampleHeader = `# Default LLM pricing used by NetBird's Agent Network cost metering.
|
||||
# GENERATED from the management catalog — do not edit this file in the
|
||||
# repository; regenerate with:
|
||||
#
|
||||
# go generate ./management/internals/modules/agentnetwork/pricing
|
||||
#
|
||||
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
|
||||
# any path configured via management.json:
|
||||
#
|
||||
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
|
||||
#
|
||||
# ) and adjust the entries you want to change. Management re-reads the
|
||||
# file periodically (mtime poll, every minute): the live table feeds the
|
||||
# proxies' cost metering and the dashboard's model-price prefill, so
|
||||
# edits apply without a restart. Your file only needs the entries you
|
||||
# want to change — but each entry REPLACES the built-in entry for that
|
||||
# surface+model whole, so repeat the cache rates you want to keep.
|
||||
# Unknown fields and negative or non-finite rates are rejected: at
|
||||
# startup that fails boot (for an explicitly configured path); at
|
||||
# runtime the previous table is kept and a warning is logged. Deleting
|
||||
# the file reverts to the built-in defaults below.
|
||||
#
|
||||
# Top-level keys are pricing surfaces — the parser shape requests are
|
||||
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
|
||||
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
|
||||
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
|
||||
# the normalized id the proxy meters (version/region suffixes stripped).
|
||||
#
|
||||
# Values are USD per 1_000 tokens. Optional cache fields:
|
||||
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
|
||||
# (a SUBSET of input tokens). Absent -> cached
|
||||
# portion bills at input_per_1k.
|
||||
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
|
||||
# (ADDITIVE to input). Absent -> input rate.
|
||||
# cache_creation_per_1k Anthropic shape: rate for cache_creation
|
||||
# tokens (ADDITIVE to input). Absent -> input
|
||||
# rate.
|
||||
`
|
||||
20
management/internals/modules/agentnetwork/pricing/gen.go
Normal file
20
management/internals/modules/agentnetwork/pricing/gen.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build ignore
|
||||
|
||||
// Regenerates defaults_llm_pricing.example.yaml from the compiled-in
|
||||
// default pricing table. Run via:
|
||||
//
|
||||
// go generate ./management/internals/modules/agentnetwork/pricing
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := os.WriteFile("defaults_llm_pricing.example.yaml", pricing.MarshalDefaultsYAML(), 0o644); err != nil {
|
||||
log.Fatalf("write defaults_llm_pricing.example.yaml: %v", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user