mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-05 23:31:29 +02:00
Compare commits
28 Commits
docs/agent
...
ssh-settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e403567b88 | ||
|
|
1ea9405557 | ||
|
|
c4d01f0c8e | ||
|
|
6495ad8687 | ||
|
|
6526fc2bec | ||
|
|
2afa69b622 | ||
|
|
2a61eac047 | ||
|
|
f2d13b884a | ||
|
|
564595d283 | ||
|
|
78c1c2fc32 | ||
|
|
bc7a15ab71 | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d | ||
|
|
7546e7751c | ||
|
|
075b319fb3 | ||
|
|
b82a42c855 | ||
|
|
e90be36cd5 | ||
|
|
d29bc23bb7 | ||
|
|
ee1389d736 | ||
|
|
6f42636514 | ||
|
|
28197e6504 | ||
|
|
2f721ec0d5 | ||
|
|
f9b412228e | ||
|
|
2bfd9fcffe | ||
|
|
7639655883 | ||
|
|
6044663788 | ||
|
|
f2318a8fef | ||
|
|
77f7e9fc91 |
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
|||||||
const allowedTags = [
|
const allowedTags = [
|
||||||
'management',
|
'management',
|
||||||
'client',
|
'client',
|
||||||
|
'android',
|
||||||
|
'ios',
|
||||||
'signal',
|
'signal',
|
||||||
'proxy',
|
'proxy',
|
||||||
'relay',
|
'relay',
|
||||||
|
|||||||
@@ -92,8 +92,15 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird (>= 0.75.0)
|
||||||
|
- libgtk-4-1 (>= 4.14)
|
||||||
|
- libwebkitgtk-6.0-4
|
||||||
|
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
@@ -113,8 +120,15 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird >= 0.75.0
|
||||||
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
|
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||||
|
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# NetBird Agent Guidelines
|
# NetBird Agent Guidelines
|
||||||
|
|
||||||
**NetBird** is an open source connectivity platform: a WireGuard®-based overlay
|
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
|
||||||
network with a control plane. The **agent** (`client/`) runs on user machines as
|
network with a control plane. The **agent** (`client/`) runs on user machines as
|
||||||
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
||||||
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ go test -race ./client/internal/dns/...
|
|||||||
|
|
||||||
## Checklist before submitting a PR
|
## Checklist before submitting a PR
|
||||||
|
|
||||||
As a critical network service and open source project, we must enforce a few
|
As a critical network service and open-source project, we must enforce a few
|
||||||
things before submitting a pull request. The
|
things before submitting a pull request. The
|
||||||
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
||||||
fill it in rather than deleting it.
|
fill it in rather than deleting it.
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu
|
|||||||

|

|
||||||
|
|
||||||
### Acknowledgements
|
### Acknowledgements
|
||||||
We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||||
|
|
||||||
### Legal
|
### Legal
|
||||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Report security issues one of these two ways:
|
|||||||
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
||||||
- **Email** — `security@netbird.io`.
|
- **Email** — `security@netbird.io`.
|
||||||
|
|
||||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than
|
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
|
||||||
filing a repository report.
|
filing a repository report.
|
||||||
|
|
||||||
### What to include
|
### What to include
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ type DnsReadyListener interface {
|
|||||||
dns.ReadyListener
|
dns.ReadyListener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||||
|
type TunSettings struct {
|
||||||
|
Routes string
|
||||||
|
SearchDomains string
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,8 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||||
|
cfgPath string
|
||||||
|
|
||||||
stateChangeMu sync.Mutex
|
stateChangeMu sync.Mutex
|
||||||
stateChangeSubID string
|
stateChangeSubID string
|
||||||
@@ -96,11 +104,12 @@ type Client struct {
|
|||||||
extendCancel context.CancelFunc
|
extendCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||||
c.stateMu.Lock()
|
c.stateMu.Lock()
|
||||||
defer c.stateMu.Unlock()
|
defer c.stateMu.Unlock()
|
||||||
c.config = cfg
|
c.config = cfg
|
||||||
c.cacheDir = cacheDir
|
c.cacheDir = cacheDir
|
||||||
|
c.cfgPath = cfgPath
|
||||||
c.connectClient = cc
|
c.connectClient = cc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
|||||||
return c.config, c.cacheDir, c.connectClient
|
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 {
|
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||||
c.stateMu.RLock()
|
c.stateMu.RLock()
|
||||||
defer c.stateMu.RUnlock()
|
defer c.stateMu.RUnlock()
|
||||||
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
defer c.ctxCancel()
|
defer c.ctxCancel()
|
||||||
c.ctxCancelLock.Unlock()
|
c.ctxCancelLock.Unlock()
|
||||||
|
|
||||||
auth := NewAuthWithConfig(ctx, cfg)
|
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||||
err = auth.login(urlOpener, isAndroidTV)
|
err = auth.login(urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -170,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||||
// is authenticated again — release the latch Status() reports from. Clear
|
// is authenticated again — release the latch Status() reports from. Clear
|
||||||
// only once the fresh connect client is installed: until then Status()
|
// only once the fresh connect client is installed: until then Status()
|
||||||
@@ -211,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
|
|||||||
return e.RenewTun(fd)
|
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.
|
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||||
// It works both with and without a running engine.
|
// It works both with and without a running engine.
|
||||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
@@ -36,12 +38,20 @@ type Auth struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuth instantiate Auth struct and validate the management URL
|
// NewAuth instantiate Auth struct and validate the management URL
|
||||||
|
//
|
||||||
|
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||||
|
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||||
|
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||||
|
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||||
|
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||||
|
// management stream rejects it with "no peer auth method provided".
|
||||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||||
inputCfg := profilemanager.ConfigInput{
|
inputCfg := profilemanager.ConfigInput{
|
||||||
|
ConfigPath: cfgPath,
|
||||||
ManagementURL: mgmURL,
|
ManagementURL: mgmURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthWithConfig instantiate Auth based on existing config
|
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
// file the config was loaded from; it identifies the profile whose account email
|
||||||
|
// backs the login_hint.
|
||||||
|
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||||
return &Auth{
|
return &Auth{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
config: config,
|
||||||
|
cfgPath: cfgPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
|
email := ""
|
||||||
if needsLogin {
|
if needsLogin {
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
jwtToken = tokenInfo.GetTokenToUse()
|
jwtToken = tokenInfo.GetTokenToUse()
|
||||||
|
email = tokenInfo.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||||
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
return fmt.Errorf("login failed: %v", err)
|
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()
|
go urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
return nil
|
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) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
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())
|
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
|
|||||||
51
client/android/login_test.go
Normal file
51
client/android/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||||
|
//
|
||||||
|
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||||
|
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||||
|
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||||
|
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||||
|
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||||
|
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||||
|
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||||
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
if first.config.PrivateKey == "" {
|
||||||
|
t.Fatal("first NewAuth produced no private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if second.config.PrivateKey != first.config.PrivateKey {
|
||||||
|
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||||
|
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||||
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||||
|
t.Fatal("NewAuth did not create a usable configuration")
|
||||||
|
}
|
||||||
|
if auth.cfgPath != cfgPath {
|
||||||
|
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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)
|
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||||
androidUsername = "android"
|
androidUsername = "android"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile represents a profile for gomobile
|
// Profile represents a profile for gomobile
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
// Email is the account this profile last logged in with, "" if it never
|
||||||
|
// completed an SSO login or was logged out. See profile_state.go.
|
||||||
|
Email string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|||||||
profiles = append(profiles, &Profile{
|
profiles = append(profiles, &Profile{
|
||||||
ID: p.ID.String(),
|
ID: p.ID.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
|
Email: pm.profileEmail(p.ID.String()),
|
||||||
IsActive: p.IsActive,
|
IsActive: p.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||||
}
|
}
|
||||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
return &Profile{
|
||||||
|
ID: prof.ID.String(),
|
||||||
|
Name: prof.Name,
|
||||||
|
Email: pm.profileEmail(prof.ID.String()),
|
||||||
|
IsActive: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||||
|
// an unresolvable path degrades to "" rather than an error.
|
||||||
|
func (pm *ProfileManager) profileEmail(id string) string {
|
||||||
|
configPath, err := pm.getProfileConfigPath(id)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return readProfileEmail(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchProfile switches to a different profile
|
// SwitchProfile switches to a different profile
|
||||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
|||||||
return fmt.Errorf("failed to save config: %w", err)
|
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)
|
log.Infof("logged out from profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
108
client/android/profile_state.go
Normal file
108
client/android/profile_state.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Android-specific config filename (different from desktop default.json)
|
||||||
|
defaultConfigFilename = "netbird.cfg"
|
||||||
|
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||||
|
profilesSubdir = "profiles"
|
||||||
|
// profileAccountSuffix names the file holding the profile's account email.
|
||||||
|
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||||
|
// there the email and the engine's state manager live in different
|
||||||
|
// directories, but on Android both resolve under files/, so sharing the name
|
||||||
|
// would have the two overwrite each other — the state manager rewrites the
|
||||||
|
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||||
|
// this package's writer does the same in reverse.
|
||||||
|
profileAccountSuffix = ".account.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileAccountPathFor derives the account file path from a profile's config
|
||||||
|
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||||
|
//
|
||||||
|
// Deriving from the config path rather than resolving the active profile keeps
|
||||||
|
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||||
|
// goroutine, so the active profile can change under a flow already in flight.
|
||||||
|
func profileAccountPathFor(configPath string) (string, error) {
|
||||||
|
if configPath == "" {
|
||||||
|
return "", fmt.Errorf("empty config path")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(configPath)
|
||||||
|
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
if stem == "" || stem == "." {
|
||||||
|
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProfileEmail returns the account email stored for the profile whose config
|
||||||
|
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||||
|
// account choice to the IdP.
|
||||||
|
func readProfileEmail(configPath string) string {
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("no profile account path for login hint: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var state profilemanager.ProfileState
|
||||||
|
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProfileEmail records the account email for the profile whose config lives
|
||||||
|
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||||
|
// email is ignored rather than blanking what is already stored.
|
||||||
|
func writeProfileEmail(configPath string, email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve profile account path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := profilemanager.ProfileState{Email: email}
|
||||||
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||||
|
return fmt.Errorf("write profile account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeProfileEmail drops the stored account email. Called on logout: while the
|
||||||
|
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||||
|
// straight back into the account just logged out of. Mirrors the desktop UI's
|
||||||
|
// RemoveProfileState call.
|
||||||
|
func removeProfileEmail(configPath string) error {
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve profile account path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove profile account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileAccountPathFor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configPath string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default profile",
|
||||||
|
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||||
|
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id profile",
|
||||||
|
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||||
|
want: filepath.FromSlash("/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: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path is rejected",
|
||||||
|
configPath: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := profileAccountPathFor(tt.configPath)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected an error, got path %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||||
|
root := "/data/data/io.netbird.client/files"
|
||||||
|
|
||||||
|
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("id profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultAccount == idAccount {
|
||||||
|
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account file must never land on the engine state file: on Android both
|
||||||
|
// resolve under files/, and the state manager rewrites the whole file from its
|
||||||
|
// own keys, so sharing a path would have the two overwrite each other. The
|
||||||
|
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||||
|
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||||
|
root := "/data/data/io.netbird.client/files"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
configPath string
|
||||||
|
engineState string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
configPath: filepath.Join(root, defaultConfigFilename),
|
||||||
|
engineState: filepath.Join(root, "state.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||||
|
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
account, err := profileAccountPathFor(c.configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.configPath, err)
|
||||||
|
}
|
||||||
|
if account == c.engineState {
|
||||||
|
t.Errorf("account file collides with the engine state file: %q", account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||||
|
if err := ensureDirFor(t, configPath); err != nil {
|
||||||
|
t.Fatalf("prepare dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != "" {
|
||||||
|
t.Errorf("expected no email before a login, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = "user@example.com"
|
||||||
|
if err := writeProfileEmail(configPath, email); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != email {
|
||||||
|
t.Errorf("got %q, want %q", got, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeProfileEmail(configPath); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
if got := readProfileEmail(configPath); got != "" {
|
||||||
|
t.Errorf("expected no email after logout, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout may run on a never-logged-in profile, so a second remove must pass.
|
||||||
|
if err := removeProfileEmail(configPath); err != nil {
|
||||||
|
t.Fatalf("second remove should be a no-op: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||||
|
if err := ensureDirFor(t, configPath); err != nil {
|
||||||
|
t.Fatalf("prepare dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = "user@example.com"
|
||||||
|
if err := writeProfileEmail(configPath, email); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||||
|
t.Fatalf("write empty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != email {
|
||||||
|
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDirFor(t *testing.T, path string) error {
|
||||||
|
t.Helper()
|
||||||
|
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||||
|
}
|
||||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||||
cfg, _, cc := c.stateSnapshot()
|
cfg, cfgPath, cc := c.authSnapshot()
|
||||||
if cfg == nil || cc == nil {
|
if cfg == nil || cc == nil {
|
||||||
return fmt.Errorf("engine is not running")
|
return fmt.Errorf("engine is not running")
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
|||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
a := &Auth{ctx: ctx, config: cfg}
|
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||||
|
// renews the session of the account already signed in, so it must not stop to
|
||||||
|
// offer a choice.
|
||||||
|
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
|
|||||||
@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
|
|||||||
stateFilePath string,
|
stateFilePath string,
|
||||||
cacheDir string,
|
cacheDir string,
|
||||||
) error {
|
) error {
|
||||||
|
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||||
|
defer notifier.Close()
|
||||||
|
|
||||||
// in case of non Android os these variables will be nil
|
// in case of non Android os these variables will be nil
|
||||||
mobileDependency := MobileDependency{
|
mobileDependency := MobileDependency{
|
||||||
TunAdapter: tunAdapter,
|
TunAdapter: tunAdapter,
|
||||||
IFaceDiscover: iFaceDiscover,
|
IFaceDiscover: iFaceDiscover,
|
||||||
NetworkChangeListener: networkChangeListener,
|
NetworkChangeListener: notifier,
|
||||||
HostDNSAddresses: dnsAddresses,
|
HostDNSAddresses: dnsAddresses,
|
||||||
DnsReadyListener: dnsReadyListener,
|
DnsReadyListener: dnsReadyListener,
|
||||||
StateFilePath: stateFilePath,
|
StateFilePath: stateFilePath,
|
||||||
|
|||||||
17
client/internal/daemonaddr/identity.go
Normal file
17
client/internal/daemonaddr/identity.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// CarriesIdentity reports whether the control channel at addr conveys the
|
||||||
|
// connecting process's identity to the daemon. A Unix socket carries peer
|
||||||
|
// credentials and a named pipe carries the client's token. Nothing else does, TCP
|
||||||
|
// included, and there the daemon can authorize a privileged operation for nobody
|
||||||
|
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
|
||||||
|
// Windows daemon on the address it served before it had a pipe.
|
||||||
|
//
|
||||||
|
// A client uses this to tell whether becoming privileged would get it anywhere.
|
||||||
|
// It answers from the scheme and nothing else, so an address it does not
|
||||||
|
// recognise counts as carrying no identity.
|
||||||
|
func CarriesIdentity(addr string) bool {
|
||||||
|
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
|
||||||
|
}
|
||||||
29
client/internal/daemonaddr/identity_test.go
Normal file
29
client/internal/daemonaddr/identity_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCarriesIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
addr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unix:///var/run/netbird.sock", true},
|
||||||
|
{"unix:///var/run/netbird/default.sock", true},
|
||||||
|
{"npipe://netbird", true},
|
||||||
|
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
|
||||||
|
{"tcp://127.0.0.1:41731", false},
|
||||||
|
{"tcp://localhost:41731", false},
|
||||||
|
{"", false},
|
||||||
|
{"/var/run/netbird.sock", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.addr, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
15
client/internal/dns/interface_index.go
Normal file
15
client/internal/dns/interface_index.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getInterfaceIndex(interfaceName string) (int, error) {
|
||||||
|
iface, err := net.InterfaceByName(interfaceName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return iface.Index, nil
|
||||||
|
}
|
||||||
35
client/internal/dns/interface_index_test.go
Normal file
35
client/internal/dns/interface_index_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetInterfaceIndexExisting(t *testing.T) {
|
||||||
|
interfaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list network interfaces: %v", err)
|
||||||
|
}
|
||||||
|
if len(interfaces) == 0 {
|
||||||
|
t.Fatal("expected at least one network interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
iface := interfaces[0]
|
||||||
|
index, err := getInterfaceIndex(iface.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up existing interface %q: %v", iface.Name, err)
|
||||||
|
}
|
||||||
|
if index != iface.Index {
|
||||||
|
t.Fatalf("expected interface index %d, got %d", iface.Index, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInterfaceIndexMissing(t *testing.T) {
|
||||||
|
index, err := getInterfaceIndex("netbird-interface-that-does-not-exist")
|
||||||
|
if index != 0 {
|
||||||
|
t.Fatalf("expected missing interface index to be 0, got %d", index)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing interface lookup to return an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(l listener.NetworkChangeListener) {
|
n.listener.OnNetworkChanged("")
|
||||||
l.OnNetworkChanged("")
|
|
||||||
}(n.listener)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
|||||||
ds.hostsDNSHolder.set(hostsDnsList)
|
ds.hostsDNSHolder.set(hostsDnsList)
|
||||||
ds.permanent = true
|
ds.permanent = true
|
||||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||||
ds.searchDomainNotifier.setListener(listener)
|
ds.searchDomainNotifier.setListener(listener)
|
||||||
setServerDns(ds)
|
setServerDns(ds)
|
||||||
return ds
|
return ds
|
||||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DefaultServer) SearchDomains() []string {
|
func (s *DefaultServer) SearchDomains() []string {
|
||||||
|
s.mux.Lock()
|
||||||
|
defer s.mux.Unlock()
|
||||||
|
return s.searchDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DefaultServer) searchDomains() []string {
|
||||||
var searchDomains []string
|
var searchDomains []string
|
||||||
|
|
||||||
for _, dConf := range s.currentConfig.Domains {
|
for _, dConf := range s.currentConfig.Domains {
|
||||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if s.searchDomainNotifier != nil {
|
if s.searchDomainNotifier != nil {
|
||||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.updateNSGroupStates(update.NameServerGroups)
|
s.updateNSGroupStates(update.NameServerGroups)
|
||||||
|
|||||||
@@ -130,8 +130,3 @@ func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeo
|
|||||||
}
|
}
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInterfaceIndex(interfaceName string) (int, error) {
|
|
||||||
iface, err := net.InterfaceByName(interfaceName)
|
|
||||||
return iface.Index, err
|
|
||||||
}
|
|
||||||
|
|||||||
74
client/internal/elevate/elevate.go
Normal file
74
client/internal/elevate/elevate.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package elevate re-runs this very executable under the operating system's own
|
||||||
|
// privilege-elevation mechanism and waits for it to finish.
|
||||||
|
//
|
||||||
|
// It exists so that a change the daemon restricts to root/administrator can be
|
||||||
|
// authorized from the GUI, by the user, at the moment they ask for it: Windows
|
||||||
|
// shows the UAC consent dialog, macOS the system authentication dialog, and
|
||||||
|
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
|
||||||
|
// asked for, are collected by the operating system and never pass through
|
||||||
|
// NetBird.
|
||||||
|
//
|
||||||
|
// What the elevated process then does is the caller's business: it is the same
|
||||||
|
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
|
||||||
|
// any other privileged caller, from the identity the kernel reports on the
|
||||||
|
// control channel. Nothing here grants privilege, and the daemon gains no new
|
||||||
|
// way to be talked into something: elevation only changes who is calling it.
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppliedMarker is what the elevated process prints on standard output once it has
|
||||||
|
// done what it was run for.
|
||||||
|
//
|
||||||
|
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
|
||||||
|
// say which process it started, so there this line is the only evidence that the
|
||||||
|
// change was applied. The other platforms have an exit code and ignore it.
|
||||||
|
const AppliedMarker = "netbird-elevated: applied"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeclined reports that the user dismissed the prompt or did not
|
||||||
|
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
|
||||||
|
// optimistic update and stays quiet.
|
||||||
|
ErrDeclined = errors.New("authorization declined")
|
||||||
|
|
||||||
|
// ErrUnavailable reports that this host has no elevation mechanism we can
|
||||||
|
// drive: no polkit on a Unix desktop, or an executable we decline to run as
|
||||||
|
// root. A caller falls back to telling the user which command to run.
|
||||||
|
ErrUnavailable = errors.New("no privilege elevation mechanism available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run runs this executable with args under the platform's elevation mechanism
|
||||||
|
// and waits for it to exit. A non-zero exit is returned as an error, so the
|
||||||
|
// caller can treat a completed Run as the operation having succeeded.
|
||||||
|
//
|
||||||
|
// The args are the caller's own command line, so they cross no privilege
|
||||||
|
// boundary: only a user who has just authenticated as an administrator can get
|
||||||
|
// them run at all.
|
||||||
|
func Run(ctx context.Context, args ...string) error {
|
||||||
|
self, err := trustedSelf()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run(ctx, self, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether Run has a mechanism to use on this host, so a caller
|
||||||
|
// can offer the prompt only when there is one and otherwise fall back to
|
||||||
|
// guidance the user can act on. It answers from what is installed, not from what
|
||||||
|
// the user is allowed to do: an administrator's password may still be required
|
||||||
|
// and may still not be given, which is ErrDeclined from Run.
|
||||||
|
func Available() bool {
|
||||||
|
if _, err := trustedSelf(); err != nil {
|
||||||
|
// Worth a line: this is also what a build run from a group-writable
|
||||||
|
// directory hits, and there is nothing in the UI to say why the offer is
|
||||||
|
// missing.
|
||||||
|
log.Debugf("not offering privilege elevation: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mechanismAvailable()
|
||||||
|
}
|
||||||
18
client/internal/elevate/output.go
Normal file
18
client/internal/elevate/output.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// noOutput stands in for a process that said nothing, so that a report of what it
|
||||||
|
// said still reads as a sentence.
|
||||||
|
const noOutput = "no output"
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return noOutput
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
21
client/internal/elevate/output_test.go
Normal file
21
client/internal/elevate/output_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstLine(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{in: "", want: noOutput},
|
||||||
|
{in: " \n ", want: noOutput},
|
||||||
|
{in: "one line", want: "one line"},
|
||||||
|
{in: "first\nsecond", want: "first"},
|
||||||
|
{in: "\nsecond\n", want: "second"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
359
client/internal/elevate/run_darwin.go
Normal file
359
client/internal/elevate/run_darwin.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorization Services, reached through purego rather than cgo so the released
|
||||||
|
// binaries keep building with CGO_ENABLED=0.
|
||||||
|
//
|
||||||
|
// The prompt belongs to this process, which is what makes it carry the
|
||||||
|
// application's name and our own explanation. Going through osascript instead puts
|
||||||
|
// the very same trampoline behind a dialog attributed to osascript, and means
|
||||||
|
// handing a shell a command line to re-parse.
|
||||||
|
//
|
||||||
|
// # On AuthorizationExecuteWithPrivileges
|
||||||
|
//
|
||||||
|
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
||||||
|
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
||||||
|
// been deprecated for many years. Do not use it in a widely distributed product."
|
||||||
|
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
||||||
|
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
||||||
|
// and NetBird already has what they would install: a launchd daemon running as
|
||||||
|
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
||||||
|
//
|
||||||
|
// The way to that without a deprecated call is to authorize the client instead of
|
||||||
|
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
||||||
|
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
||||||
|
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
||||||
|
// better design and it is where this should end up. It also means the daemon
|
||||||
|
// accepting an authorization over its control socket, which is a new way to be
|
||||||
|
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
||||||
|
// bundled in with the rest of this.
|
||||||
|
//
|
||||||
|
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
||||||
|
// resolved with an error rather than a panic, so a macOS that has dropped this
|
||||||
|
// function leaves the app offering the user a command instead of crashing on the
|
||||||
|
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
||||||
|
// fallback is the same one an agent-less Linux session gets. And the whole path
|
||||||
|
// runs under guard, which turns a panic out of the FFI layer into that same
|
||||||
|
// fallback.
|
||||||
|
//
|
||||||
|
// The trampoline passes on the environment it was given, so what it starts as root
|
||||||
|
// must be an executable this user's peers cannot influence: that is what
|
||||||
|
// trustedSelf refuses, and what signing the binary settles for the loader.
|
||||||
|
|
||||||
|
const (
|
||||||
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||||
|
libSystem = "/usr/lib/libSystem.B.dylib"
|
||||||
|
|
||||||
|
// trampoline is what the framework hands the tool to. Present on every macOS,
|
||||||
|
// and worth confirming before offering a prompt rather than mid-prompt.
|
||||||
|
trampoline = "/usr/libexec/security_authtrampoline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rightExecute is the right an administrator holds, and what
|
||||||
|
// AuthorizationExecuteWithPrivileges requires of us.
|
||||||
|
const rightExecute = "system.privilege.admin"
|
||||||
|
|
||||||
|
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
||||||
|
// the system's in the dialog. It is about the change rather than the mechanism.
|
||||||
|
const (
|
||||||
|
promptKey = "prompt"
|
||||||
|
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
||||||
|
)
|
||||||
|
|
||||||
|
// OSStatus values from SecBase.h that mean something to us; anything else is
|
||||||
|
// reported as it comes.
|
||||||
|
const (
|
||||||
|
errAuthorizationSuccess = 0
|
||||||
|
errAuthorizationDenied = -60005
|
||||||
|
errAuthorizationCanceled = -60006
|
||||||
|
errAuthorizationInteractionNotAllowed = -60007
|
||||||
|
errAuthorizationToolExecuteFailure = -60031
|
||||||
|
errAuthorizationToolEnvironmentError = -60032
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizationFlags from Authorization.h.
|
||||||
|
const (
|
||||||
|
flagDefaults = 0
|
||||||
|
flagInteractionAllowed = 1 << 0
|
||||||
|
flagExtendRights = 1 << 1
|
||||||
|
flagDestroyRights = 1 << 3
|
||||||
|
flagPreAuthorize = 1 << 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
||||||
|
// meaning to. 32 bytes on both amd64 and arm64.
|
||||||
|
type authorizationItem struct {
|
||||||
|
name *byte
|
||||||
|
valueLength uintptr
|
||||||
|
value unsafe.Pointer
|
||||||
|
// flags is reserved by the API and always zero. Declared because the layout
|
||||||
|
// is the contract: without it the struct is 24 bytes where C reads 32.
|
||||||
|
flags uint32 //nolint:unused // part of the C layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
||||||
|
// AuthorizationRights and an AuthorizationEnvironment.
|
||||||
|
type authorizationItemSet struct {
|
||||||
|
count uint32
|
||||||
|
items *authorizationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
||||||
|
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
||||||
|
authorizationFree func(authorization uintptr, flags uint32) int32
|
||||||
|
fileno func(stream uintptr) int32
|
||||||
|
fclose func(stream uintptr) int32
|
||||||
|
|
||||||
|
loadOnce sync.Once
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
||||||
|
// that is no longer there, leaves the host without a mechanism rather than taking
|
||||||
|
// the process down with it: see the note on deprecation above.
|
||||||
|
func load() error {
|
||||||
|
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard turns a panic out of the FFI layer into an error, so an API that has
|
||||||
|
// changed under us costs the user a prompt rather than the window they were
|
||||||
|
// clicking in. purego panics on a signature it cannot map, and this is the one
|
||||||
|
// place in the client that calls a deprecated system function.
|
||||||
|
//
|
||||||
|
// It catches Go panics, which is what purego raises. A fault inside the framework
|
||||||
|
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
||||||
|
// stands between us and that.
|
||||||
|
func guard(what string, fn func() error) (err error) {
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Errorf("%s panicked: %v", what, r)
|
||||||
|
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve() error {
|
||||||
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||||
|
}
|
||||||
|
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", libSystem, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
||||||
|
// deprecated function's disappearance should reach the user.
|
||||||
|
for _, fn := range []struct {
|
||||||
|
ptr any
|
||||||
|
handle uintptr
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&authorizationCreate, security, "AuthorizationCreate"},
|
||||||
|
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
||||||
|
{&authorizationFree, security, "AuthorizationFree"},
|
||||||
|
{&fileno, system, "fileno"},
|
||||||
|
{&fclose, system, "fclose"},
|
||||||
|
} {
|
||||||
|
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||||
|
}
|
||||||
|
if symbol == 0 {
|
||||||
|
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(fn.ptr, symbol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run asks the system to run self as root: first for the right, which is what puts
|
||||||
|
// up the authentication dialog and collects the password or takes the Touch ID,
|
||||||
|
// then for the tool. The credentials go to the system's authorization trampoline
|
||||||
|
// and never to us.
|
||||||
|
//
|
||||||
|
// The context bounds only our own waiting; the dialog belongs to the system and
|
||||||
|
// closes when the user answers it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return guard("asking for privileges", func() error {
|
||||||
|
authorization, err := authorize()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer authorizationFree(authorization, flagDestroyRights)
|
||||||
|
|
||||||
|
return execute(ctx, authorization, self, args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(trampoline)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
||||||
|
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
||||||
|
// both are the user's answer rather than a failure.
|
||||||
|
func authorize() (uintptr, error) {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment,
|
||||||
|
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
return authorization, nil
|
||||||
|
case errAuthorizationCanceled, errAuthorizationDenied:
|
||||||
|
return 0, ErrDeclined
|
||||||
|
case errAuthorizationInteractionNotAllowed:
|
||||||
|
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
||||||
|
// a session with no window server.
|
||||||
|
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
||||||
|
// it is given until the tool closes it.
|
||||||
|
//
|
||||||
|
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
||||||
|
// process it started, which is why the one-shot says so itself: what it prints is
|
||||||
|
// the only evidence that the change was applied.
|
||||||
|
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
argv := make([]uintptr, 0, len(args)+1)
|
||||||
|
for _, arg := range args {
|
||||||
|
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
||||||
|
}
|
||||||
|
argv = append(argv, 0)
|
||||||
|
pinner.Pin(&argv[0])
|
||||||
|
|
||||||
|
var pipe uintptr
|
||||||
|
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
case errAuthorizationCanceled:
|
||||||
|
return ErrDeclined
|
||||||
|
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
||||||
|
// The right was granted and the tool still did not start. Nothing the user
|
||||||
|
// can do about it from here, so point them at the command instead.
|
||||||
|
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := readPipe(ctx, pipe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkApplied(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkApplied reads the one-shot's report, which stands in for the exit status
|
||||||
|
// there is no way to ask for here. A run that said nothing did not apply the
|
||||||
|
// change, whatever else went on.
|
||||||
|
func checkApplied(out string) error {
|
||||||
|
if !strings.Contains(out, AppliedMarker) {
|
||||||
|
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPipe drains the tool's output, which ends when the tool exits and is
|
||||||
|
// therefore also how we wait for it.
|
||||||
|
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
||||||
|
if pipe == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
defer fclose(pipe)
|
||||||
|
|
||||||
|
fd := int(fileno(pipe))
|
||||||
|
if fd < 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
n, err := syscall.Read(fd, buf)
|
||||||
|
if n > 0 {
|
||||||
|
out.Write(buf[:n])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, syscall.EINTR):
|
||||||
|
// A signal landed mid-read, which says nothing about the tool.
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
log.Debugf("read the elevated process's output: %v", err)
|
||||||
|
return out.String(), nil
|
||||||
|
case n <= 0:
|
||||||
|
// End of file: the tool closed the pipe, which is how it exiting
|
||||||
|
// reaches us.
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
||||||
|
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
||||||
|
pinner.Pin(&items[0])
|
||||||
|
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
||||||
|
pinner.Pin(set)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptItem is the environment entry carrying our sentence for the dialog.
|
||||||
|
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
||||||
|
value := []byte(promptText)
|
||||||
|
pinner.Pin(&value[0])
|
||||||
|
return authorizationItem{
|
||||||
|
name: cString(pinner, promptKey),
|
||||||
|
valueLength: uintptr(len(value)),
|
||||||
|
value: unsafe.Pointer(&value[0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
||||||
|
// the duration of the call.
|
||||||
|
func cString(pinner *runtime.Pinner, s string) *byte {
|
||||||
|
b := append([]byte(s), 0)
|
||||||
|
pinner.Pin(&b[0])
|
||||||
|
return &b[0]
|
||||||
|
}
|
||||||
111
client/internal/elevate/run_darwin_test.go
Normal file
111
client/internal/elevate/run_darwin_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The framework has to load and the symbols have to resolve, or nothing else here
|
||||||
|
// means anything.
|
||||||
|
func TestSecurityFrameworkLoads(t *testing.T) {
|
||||||
|
require.NoError(t, load(), "Security.framework must open")
|
||||||
|
|
||||||
|
for name, fn := range map[string]any{
|
||||||
|
"AuthorizationCreate": authorizationCreate,
|
||||||
|
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
|
||||||
|
"AuthorizationFree": authorizationFree,
|
||||||
|
"fileno": fileno,
|
||||||
|
"fclose": fclose,
|
||||||
|
} {
|
||||||
|
assert.NotNil(t, fn, "%s must resolve", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request with no interaction allowed exercises the whole call — the rights and
|
||||||
|
// environment structs, and the OSStatus that comes back — without a dialog anybody
|
||||||
|
// has to answer. What the system decides is its business; that it decides at all is
|
||||||
|
// what this asserts.
|
||||||
|
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
// Credentials were already cached for this session.
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
|
||||||
|
// The expected answers when nobody may be asked.
|
||||||
|
default:
|
||||||
|
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asking with a right nobody has must not be mistaken for a declined prompt: the
|
||||||
|
// caller would report nothing at all.
|
||||||
|
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
if status == errAuthorizationSuccess {
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
}
|
||||||
|
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMechanismAvailable(t *testing.T) {
|
||||||
|
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-shot's report is what stands in for an exit status here, so a run that
|
||||||
|
// says nothing must not read as success.
|
||||||
|
func TestCheckApplied(t *testing.T) {
|
||||||
|
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
|
||||||
|
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
|
||||||
|
|
||||||
|
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
|
||||||
|
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
|
||||||
|
// the outcome that offers the user the command instead of taking the window down.
|
||||||
|
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
|
||||||
|
err := guard("pretending to call something", func() error {
|
||||||
|
panic("purego: signature it cannot map")
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
|
||||||
|
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard wraps every darwin path, so what a caller switches on has to survive it.
|
||||||
|
func TestGuardPassesErrorsThrough(t *testing.T) {
|
||||||
|
sentinel := errors.New("the call itself failed")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
|
||||||
|
"the error it was given")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
|
||||||
|
"a declined prompt stays declined")
|
||||||
|
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
|
||||||
|
}
|
||||||
117
client/internal/elevate/run_unix.go
Normal file
117
client/internal/elevate/run_unix.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pkexec exit codes that are about the authorization rather than about the program
|
||||||
|
// we asked it to run. The manual page reserves both.
|
||||||
|
const (
|
||||||
|
// exitDismissed is returned when the user dismissed the authentication
|
||||||
|
// dialog.
|
||||||
|
exitDismissed = 126
|
||||||
|
// exitNotAuthorized is returned when the authorization was not obtained. That
|
||||||
|
// covers the user saying no as well as pkexec having had nobody to ask: see
|
||||||
|
// noAgentMarkers.
|
||||||
|
exitNotAuthorized = 127
|
||||||
|
)
|
||||||
|
|
||||||
|
// exitNotAuthorized covers three different endings that only pkexec's own words
|
||||||
|
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
|
||||||
|
// ones written below.
|
||||||
|
//
|
||||||
|
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
|
||||||
|
// an account that may not elevate at all.
|
||||||
|
const refusedMarker = "Not authorized"
|
||||||
|
|
||||||
|
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
|
||||||
|
// session, and no controlling terminal for the textual agent it falls back to.
|
||||||
|
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
|
||||||
|
|
||||||
|
// run asks polkit to run self as root. pkexec hands the request to the session's
|
||||||
|
// polkit agent, which is what prompts and what collects any password; we see only
|
||||||
|
// its verdict.
|
||||||
|
//
|
||||||
|
// The environment is otherwise deliberately not passed through: pkexec clears it
|
||||||
|
// bar a small allowlist, and the one-shot needs nothing from it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
pkexec, err := exec.LookPath("pkexec")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
|
||||||
|
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
// The one-shot reports itself on stdout for macOS's sake, where there is no
|
||||||
|
// exit status to read. Here there is one, so that line is noise.
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf("run pkexec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched against everything pkexec said, reported as one line: a complaint
|
||||||
|
// that is not the first thing printed still has to be recognised, and reading
|
||||||
|
// it as a refusal would swallow it.
|
||||||
|
full := stderr.String()
|
||||||
|
out := firstLine(full)
|
||||||
|
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case exitDismissed:
|
||||||
|
return ErrDeclined
|
||||||
|
case exitNotAuthorized:
|
||||||
|
return notAuthorized(full, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
|
||||||
|
//
|
||||||
|
// It also returns that code when the authorization succeeded and it then could
|
||||||
|
// not run the program, so a refusal has to be recognised rather than assumed:
|
||||||
|
// reading every one of these as "the user said no" would revert the control in
|
||||||
|
// silence on a host where elevation is broken.
|
||||||
|
func notAuthorized(full, out string) error {
|
||||||
|
switch {
|
||||||
|
case hasAny(full, noAgentMarkers):
|
||||||
|
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
|
||||||
|
case out == noOutput, strings.Contains(full, refusedMarker):
|
||||||
|
// The user said no, which needs no message; that an account barred from
|
||||||
|
// elevating altogether lands here too is why the reason is kept.
|
||||||
|
return fmt.Errorf("%w: %s", ErrDeclined, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(s string, markers []string) bool {
|
||||||
|
for _, marker := range markers {
|
||||||
|
if strings.Contains(s, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
_, err := exec.LookPath("pkexec")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
110
client/internal/elevate/run_unix_test.go
Normal file
110
client/internal/elevate/run_unix_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
|
||||||
|
// mapping from polkit's exit codes onto our errors can be exercised without a
|
||||||
|
// polkit agent.
|
||||||
|
func fakePkexec(t *testing.T, exitCode int, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMapsPkexecExitCodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
stderr string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "applied", exitCode: 0},
|
||||||
|
{
|
||||||
|
name: "dialog dismissed",
|
||||||
|
exitCode: exitDismissed,
|
||||||
|
stderr: "Error executing command as another user: Request dismissed",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// What a graphical agent reports for a cancelled prompt. Not a
|
||||||
|
// failure: the user was asked and answered.
|
||||||
|
name: "prompt cancelled",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: Not authorized",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same status, but pkexec never got to ask anybody.
|
||||||
|
name: "no agent and no terminal to fall back on",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
|
||||||
|
wantErr: ErrUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// And the same status again once the authorization succeeded and
|
||||||
|
// pkexec could not run what it had been authorized to run. Reading
|
||||||
|
// that as a refusal would revert the control in silence on a host
|
||||||
|
// where elevation is broken.
|
||||||
|
name: "authorized but not runnable",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: No such file or directory",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fakePkexec(t, tt.exitCode, tt.stderr)
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
|
||||||
|
switch {
|
||||||
|
case tt.wantErr != nil:
|
||||||
|
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
case tt.exitCode == 0:
|
||||||
|
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
|
||||||
|
default:
|
||||||
|
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exit code that is not polkit's is the one-shot's own failure, and has to
|
||||||
|
// stay distinguishable from a declined prompt: the caller reports it.
|
||||||
|
func TestRunReportsOneShotFailure(t *testing.T) {
|
||||||
|
fakePkexec(t, 3, "the one-shot said no")
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
|
||||||
|
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
|
||||||
|
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
|
||||||
|
}
|
||||||
19
client/internal/elevate/run_unsupported.go
Normal file
19
client/internal/elevate/run_unsupported.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// run reports that this platform has no elevation prompt to drive.
|
||||||
|
//
|
||||||
|
// The desktop app is the only caller and is not built for any of these: mobile
|
||||||
|
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
|
||||||
|
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
|
||||||
|
// changes.
|
||||||
|
func run(context.Context, string, []string) error {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
193
client/internal/elevate/run_windows.go
Normal file
193
client/internal/elevate/run_windows.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// seeMaskNoCloseProcess keeps the started process's handle open in
|
||||||
|
// hProcess so we can wait for it.
|
||||||
|
seeMaskNoCloseProcess = 0x00000040
|
||||||
|
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
|
||||||
|
// which it must when the calling thread does not pump messages.
|
||||||
|
seeMaskNoAsync = 0x00000100
|
||||||
|
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
|
||||||
|
// dialog is not one of them and still appears.
|
||||||
|
seeMaskFlagNoUI = 0x00000400
|
||||||
|
|
||||||
|
// swHide: the one-shot has no window to show.
|
||||||
|
swHide = 0
|
||||||
|
|
||||||
|
// sFalse (S_FALSE) answers CoInitializeEx when COM is already up on this
|
||||||
|
// thread in the mode we asked for; rpcChangedMode (RPC_E_CHANGED_MODE) when
|
||||||
|
// it is up in the other one.
|
||||||
|
sFalse = 1
|
||||||
|
rpcChangedMode = 0x80010106
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
|
||||||
|
// padding match the C layout on both 386 and amd64.
|
||||||
|
type shellExecuteInfoW struct {
|
||||||
|
cbSize uint32
|
||||||
|
fMask uint32
|
||||||
|
hwnd windows.HWND
|
||||||
|
lpVerb *uint16
|
||||||
|
lpFile *uint16
|
||||||
|
lpParameters *uint16
|
||||||
|
lpDirectory *uint16
|
||||||
|
nShow int32
|
||||||
|
hInstApp windows.Handle
|
||||||
|
lpIDList uintptr
|
||||||
|
lpClass *uint16
|
||||||
|
hkeyClass windows.Handle
|
||||||
|
dwHotKey uint32
|
||||||
|
hIconOrMonitor windows.Handle
|
||||||
|
hProcess windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
|
||||||
|
)
|
||||||
|
|
||||||
|
// run starts self elevated with the "runas" verb, which is what raises the UAC
|
||||||
|
// consent dialog, and waits for it to finish. Windows decides whether consent is
|
||||||
|
// enough or an administrator's credentials are needed, and collects them itself.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
verb, err := windows.UTF16PtrFromString("runas")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode verb: %w", err)
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(self)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode %s: %w", self, err)
|
||||||
|
}
|
||||||
|
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := shellExecuteInfoW{
|
||||||
|
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
|
||||||
|
hwnd: ownerWindow(),
|
||||||
|
lpVerb: verb,
|
||||||
|
lpFile: file,
|
||||||
|
lpParameters: params,
|
||||||
|
nShow: swHide,
|
||||||
|
}
|
||||||
|
info.cbSize = uint32(unsafe.Sizeof(info))
|
||||||
|
|
||||||
|
process, err := shellExecute(&info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := windows.CloseHandle(process); err != nil {
|
||||||
|
log.Debugf("close elevated process handle: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return waitForProcess(ctx, process)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
|
||||||
|
// the calling thread, so the goroutine is pinned to one for the duration and COM
|
||||||
|
// is set up on it; an "already initialised, different mode" answer is fine,
|
||||||
|
// because then somebody else has done it for us.
|
||||||
|
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
|
||||||
|
case err == nil, isHResult(err, sFalse):
|
||||||
|
// Ours, or already initialised in the same mode: either way this call
|
||||||
|
// counts and has to be balanced.
|
||||||
|
defer windows.CoUninitialize()
|
||||||
|
case isHResult(err, rpcChangedMode):
|
||||||
|
// The thread is already in the other apartment model. ShellExecuteExW
|
||||||
|
// works there too, and there is nothing of ours to balance.
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("initialise COM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
|
||||||
|
if ret != 0 {
|
||||||
|
return info.hProcess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
|
||||||
|
return 0, ErrDeclined
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("run elevated: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerWindow returns this process's foreground window, and 0 when the window in
|
||||||
|
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
|
||||||
|
// as the parent for the UI it raises, which is what keeps the consent dialog in
|
||||||
|
// front of the window the user was just clicking in instead of behind it. It is
|
||||||
|
// also what a remote-desktop session needs to place the dialog at all when the
|
||||||
|
// secure desktop is switched off.
|
||||||
|
func ownerWindow() windows.HWND {
|
||||||
|
hwnd := windows.GetForegroundWindow()
|
||||||
|
if hwnd == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid uint32
|
||||||
|
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
|
||||||
|
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pid != windows.GetCurrentProcessId() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return hwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
|
||||||
|
// returns its HRESULT as an Errno, so the comparison is on the raw value.
|
||||||
|
func isHResult(err error, hresult uintptr) bool {
|
||||||
|
var errno windows.Errno
|
||||||
|
return errors.As(err, &errno) && uintptr(errno) == hresult
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProcess(ctx context.Context, process windows.Handle) error {
|
||||||
|
// The wait is interruptible so a cancelled context stops us waiting on a
|
||||||
|
// consent dialog nobody is going to answer. The elevated process is not
|
||||||
|
// ours to kill, and it either applies the change or does not.
|
||||||
|
for {
|
||||||
|
event, err := windows.WaitForSingleObject(process, 250)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for the elevated process: %w", err)
|
||||||
|
}
|
||||||
|
if event == uint32(windows.WAIT_OBJECT_0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var code uint32
|
||||||
|
if err := windows.GetExitCodeProcess(process, &code); err != nil {
|
||||||
|
return fmt.Errorf("read the elevated process's exit code: %w", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d", code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
|
||||||
|
// is an administrator and for an administrator's credentials when they are not,
|
||||||
|
// so there is always something to ask.
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
40
client/internal/elevate/trusted.go
Normal file
40
client/internal/elevate/trusted.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trustedSelf returns the path of this executable, provided it is one we are
|
||||||
|
// willing to have run as root.
|
||||||
|
//
|
||||||
|
// The check is what keeps elevation from becoming a way to launder someone
|
||||||
|
// else's code into a root process: the user consents to NetBird being elevated,
|
||||||
|
// having been shown NetBird's name, so what runs must be the file NetBird was
|
||||||
|
// installed as and not something a third party could have swapped for it. An
|
||||||
|
// executable only its owner can write is that; anything wider is refused, and
|
||||||
|
// the caller falls back to showing the command instead.
|
||||||
|
//
|
||||||
|
// The owner writing to their own executable is not part of that threat: code
|
||||||
|
// running as the user can already prompt them for anything, and could just as
|
||||||
|
// well ask them to run the command by hand. What matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedSelf() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("locate this executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks so the checks below apply to the file that would actually
|
||||||
|
// be executed, not to a link somebody else may control.
|
||||||
|
resolved, err := filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve %s: %w", exe, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkOnlyOwnerWritable(resolved); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
10
client/internal/elevate/trusted_group_darwin.go
Normal file
10
client/internal/elevate/trusted_group_darwin.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it.
|
||||||
|
//
|
||||||
|
// macOS installs applications as root:admin, mode 0775, /Applications included,
|
||||||
|
// so requiring owner-only write would reject every normal install. Group admin
|
||||||
|
// (gid 80) is exactly the set of accounts that can answer the authentication
|
||||||
|
// dialog, so its write access grants nothing the prompt would not.
|
||||||
|
var adminWriteGIDs = []uint32{0, 80}
|
||||||
9
client/internal/elevate/trusted_group_unix.go
Normal file
9
client/internal/elevate/trusted_group_unix.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it. Only root's own group qualifies here:
|
||||||
|
// a distribution installs into root-owned directories, and there is no
|
||||||
|
// system-wide administrators group that both writes them and answers polkit.
|
||||||
|
var adminWriteGIDs = []uint32{0}
|
||||||
146
client/internal/elevate/trusted_unix.go
Normal file
146
client/internal/elevate/trusted_unix.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// groupFile lists which accounts are in which group, for the membership a user
|
||||||
|
// private group's name does not state: see groupHasOtherMembers.
|
||||||
|
const groupFile = "/etc/group"
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
|
||||||
|
// to it, is owned by either root or this user and writable by nobody who could not
|
||||||
|
// already act as its owner. A writable directory is as good as a writable file,
|
||||||
|
// since anything in it can be replaced, so the whole chain is checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
self := uint32(os.Getuid())
|
||||||
|
|
||||||
|
for dir := path; ; dir = filepath.Dir(dir) {
|
||||||
|
info, err := os.Lstat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("file ownership is unavailable on this platform")
|
||||||
|
}
|
||||||
|
if stat.Uid != 0 && stat.Uid != self {
|
||||||
|
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent := filepath.Dir(dir); parent == dir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
|
||||||
|
// On a directory the sticky bit stands in for the write bits: whoever may
|
||||||
|
// write there still cannot replace an entry they do not own, which is the
|
||||||
|
// only thing that would matter to us. /tmp is the usual example.
|
||||||
|
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
|
||||||
|
|
||||||
|
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBitsAllow decides on the permission bits alone, given whether the group's
|
||||||
|
// write access has been vouched for.
|
||||||
|
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
|
||||||
|
if sticky {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if perm&0o020 != 0 && !groupAllowed {
|
||||||
|
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
|
||||||
|
}
|
||||||
|
if perm&0o002 != 0 {
|
||||||
|
return fmt.Errorf("%s is world-writable (%v)", path, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupWriteAllowed reports whether a group's write access to a file owned by uid
|
||||||
|
// puts it in reach of anyone who could not already act as that owner.
|
||||||
|
//
|
||||||
|
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
|
||||||
|
// answer the elevation prompt anyway. And a user private group is how Debian,
|
||||||
|
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
|
||||||
|
// everything built in it group-writable, so refusing that would refuse every
|
||||||
|
// build not installed from a package.
|
||||||
|
func groupWriteAllowed(uid, gid uint32) bool {
|
||||||
|
if slices.Contains(adminWriteGIDs, gid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := user.LookupGroupId(strconv.FormatUint(uint64(gid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
owner, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.Name != owner.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !groupHasOtherMembers(groupFile, group.Name, owner.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupHasOtherMembers reports whether the group lists a member besides owner.
|
||||||
|
//
|
||||||
|
// Sharing the owner's name is what a user private group is recognised by, and it
|
||||||
|
// says nothing about who is in it: a group that has since gained a member is
|
||||||
|
// still named that way, and that member can write whatever the group can. So the
|
||||||
|
// membership is read rather than assumed. A group this file does not describe,
|
||||||
|
// because it comes from LDAP or another NSS source, cannot be answered here and
|
||||||
|
// leaves the name as the only thing to go on.
|
||||||
|
func groupHasOtherMembers(path, name, owner string) bool {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot read %s for the members of group %q: %v", path, name, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
log.Debugf("close %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
// name:password:gid:member,member
|
||||||
|
fields := strings.Split(scanner.Text(), ":")
|
||||||
|
if len(fields) < 4 || fields[0] != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for member := range strings.SplitSeq(fields[3], ",") {
|
||||||
|
if member != "" && member != owner {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
log.Debugf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
177
client/internal/elevate/trusted_unix_test.go
Normal file
177
client/internal/elevate/trusted_unix_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
|
||||||
|
// numbered directory with 0777 minus the umask, so under the common 002 umask it
|
||||||
|
// is group-writable and would fail the check under test on its own.
|
||||||
|
func ownerOnlyDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, "netbird-ui")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
|
||||||
|
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
|
||||||
|
path := writeExecutable(t, ownerOnlyDir(t))
|
||||||
|
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The permission policy on its own, without a filesystem to arrange: whether the
|
||||||
|
// group has been vouched for is the only thing that makes group write acceptable.
|
||||||
|
func TestWriteBitsAllow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
perm os.FileMode
|
||||||
|
sticky bool
|
||||||
|
groupAllowed bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "owner only", perm: 0o755},
|
||||||
|
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
|
||||||
|
{name: "group write in a shared group", perm: 0o775, wantErr: true},
|
||||||
|
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
|
||||||
|
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
|
||||||
|
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build under a home directory on a distribution with a 002 umask, which is what
|
||||||
|
// a locally built or tarball-installed binary looks like. Its group has no members
|
||||||
|
// but its owner, so it is as good as owner-only.
|
||||||
|
//
|
||||||
|
// Whether this host is such a distribution is read from the environment rather than
|
||||||
|
// from groupWriteAllowed: asking the function under test whether to run would let
|
||||||
|
// it skip its own coverage away if it regressed to refusing everything.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
|
||||||
|
requirePrivatePrimaryGroup(t)
|
||||||
|
|
||||||
|
dir := ownerOnlyDir(t)
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group that shares its owner's name but has gained another member is no longer
|
||||||
|
// private, and its write access reaches an account that could not elevate.
|
||||||
|
func TestGroupHasOtherMembers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entry string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "no members", entry: "vma:x:1000:"},
|
||||||
|
{name: "only the owner", entry: "vma:x:1000:vma"},
|
||||||
|
{name: "another member", entry: "vma:x:1000:bob", want: true},
|
||||||
|
{name: "the owner and another", entry: "vma:x:1000:vma,bob", want: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "group")
|
||||||
|
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, groupHasOtherMembers(path, "vma", "vma"), "entry %q", tt.entry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group file that says nothing about the group leaves the name as the only thing
|
||||||
|
// to go on, so the private-group allowance stands rather than collapsing on every
|
||||||
|
// host whose groups come from LDAP.
|
||||||
|
func TestGroupHasOtherMembersTolerantOfAnUnknownGroup(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "group")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
|
||||||
|
|
||||||
|
assert.False(t, groupHasOtherMembers(path, "vma", "vma"), "a group the file does not describe")
|
||||||
|
assert.False(t, groupHasOtherMembers(filepath.Join(t.TempDir(), "absent"), "vma", "vma"),
|
||||||
|
"no group file at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A writable directory is as good as a writable file: whoever can write the
|
||||||
|
// directory can put a different binary at the same path.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "bin")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sticky world-writable directory is exempt: the sticky bit is what stops one
|
||||||
|
// user replacing another's entries. /tmp is why this matters.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "sticky")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
|
||||||
|
assert.Error(t, err, "an executable that is not there must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
|
||||||
|
// which is what the user-private-group allowance is about.
|
||||||
|
func requirePrivatePrimaryGroup(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
self, err := user.Current()
|
||||||
|
require.NoError(t, err, "look up the test user")
|
||||||
|
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
|
||||||
|
require.NoError(t, err, "look up the test user's primary group")
|
||||||
|
|
||||||
|
if group.Name != self.Username {
|
||||||
|
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
|
||||||
|
}
|
||||||
|
if groupHasOtherMembers(groupFile, group.Name, self.Username) {
|
||||||
|
t.Skipf("group %q has other members, so it is not a private group", group.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
215
client/internal/elevate/trusted_windows.go
Normal file
215
client/internal/elevate/trusted_windows.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
|
||||||
|
// right to delete an entry of a directory without holding DELETE on it.
|
||||||
|
fileDeleteChild = 0x00000040
|
||||||
|
|
||||||
|
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
|
||||||
|
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
|
||||||
|
accessAllowedCallbackACEType = 0x9
|
||||||
|
|
||||||
|
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
|
||||||
|
// SID is not at SidStart. They occur on directory-service objects rather
|
||||||
|
// than files, and are refused rather than skipped: see aceTrustee.
|
||||||
|
accessAllowedObjectACEType = 0x5
|
||||||
|
accessAllowedCallbackObjectACEType = 0xB
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
|
||||||
|
// or take it over and then do so.
|
||||||
|
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
|
||||||
|
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
|
||||||
|
windows.GENERIC_WRITE | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// dirWriteAccess are the rights over a directory that let a trustee replace an
|
||||||
|
// entry somebody else owns. Creating a new entry is not one of them, which is
|
||||||
|
// what the Unix sticky bit says in one bit: the root of every volume grants
|
||||||
|
// BUILTIN\Users the right to add directories under it, and that reaches nothing
|
||||||
|
// already there.
|
||||||
|
const dirWriteAccess = fileDeleteChild | windows.DELETE |
|
||||||
|
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
|
||||||
|
// well-known constant for it.
|
||||||
|
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory
|
||||||
|
// leading to it, is owned by an account that can elevate (or by this user) and
|
||||||
|
// grants write access to nobody else. A writable directory is as good as a
|
||||||
|
// writable file, since an entry in it can be replaced, so the whole chain is
|
||||||
|
// checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
owners, err := trustedOwners()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writers, err := trustedWriters(owners)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
|
||||||
|
for target := path; ; target = filepath.Dir(target) {
|
||||||
|
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if parent := filepath.Dir(target); parent == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writeAccess = dirWriteAccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedOwners are the accounts we accept as the owner of the executable and of
|
||||||
|
// the directories above it: the ones that can already answer the UAC prompt,
|
||||||
|
// plus this user, whose own executable is theirs to write. Code running as the
|
||||||
|
// user could prompt them for anything anyway; what matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedOwners() ([]*windows.SID, error) {
|
||||||
|
self, err := currentUserSID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := []*windows.SID{self}
|
||||||
|
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
|
||||||
|
windows.WinLocalSystemSid,
|
||||||
|
windows.WinBuiltinAdministratorsSid,
|
||||||
|
} {
|
||||||
|
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
|
||||||
|
}
|
||||||
|
owners = append(owners, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer, err := windows.StringToSid(trustedInstallerSID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(owners, installer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedWriters are the trustees whose write access does not widen who could
|
||||||
|
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
|
||||||
|
// resolves to the object's owner and is therefore already vetted.
|
||||||
|
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
|
||||||
|
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(slices.Clone(owners), creatorOwner), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
|
||||||
|
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read security descriptor of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, _, err := sd.Owner()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read owner of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(owners, owner) {
|
||||||
|
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
dacl, _, err := sd.DACL()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read DACL of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// A NULL DACL grants everyone everything; only an absent security
|
||||||
|
// descriptor would have got us here without one, and neither is trustworthy.
|
||||||
|
if dacl == nil {
|
||||||
|
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDACL(path, dacl, writeAccess, writers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDACL refuses an ACL that grants write access to a trustee outside
|
||||||
|
// writers.
|
||||||
|
//
|
||||||
|
// An allowlist, because the trustees that must not have it cannot be listed: an
|
||||||
|
// ACE naming an ordinary user account hands that account the same power as one
|
||||||
|
// naming Everyone, and only the accounts that may hold it are knowable.
|
||||||
|
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
|
||||||
|
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||||
|
var ace *windows.ACCESS_ALLOWED_ACE
|
||||||
|
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||||
|
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
// An inherit-only ACE says what children of this object get, not what
|
||||||
|
// this object grants.
|
||||||
|
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ace.Mask&writeAccess == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
|
||||||
|
if !isAllowACE(ace.Header.AceType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trustee, err := aceTrustee(ace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(writers, trustee) {
|
||||||
|
return fmt.Errorf("%s grants write access to %s", path, trustee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowACE reports whether an ACE type grants rights, rather than denying,
|
||||||
|
// auditing or labelling them.
|
||||||
|
func isAllowACE(aceType uint8) bool {
|
||||||
|
switch aceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
|
||||||
|
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
|
||||||
|
// cannot be located is an error rather than something to skip past: being unable
|
||||||
|
// to read who is being given write access is a refusal.
|
||||||
|
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
|
||||||
|
switch ace.Header.AceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
|
||||||
|
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
|
||||||
|
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
|
||||||
|
return slices.ContainsFunc(sids, sid.Equals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserSID() (*windows.SID, error) {
|
||||||
|
token := windows.GetCurrentProcessToken()
|
||||||
|
user, err := token.GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read this process's user: %w", err)
|
||||||
|
}
|
||||||
|
return user.User.Sid, nil
|
||||||
|
}
|
||||||
126
client/internal/elevate/trusted_windows_test.go
Normal file
126
client/internal/elevate/trusted_windows_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file the test user created under their own profile, which is what a per-user
|
||||||
|
// install looks like. The whole chain up to the volume root is walked, so this is
|
||||||
|
// also what says the walk does not refuse an ordinary Windows installation: the
|
||||||
|
// root of every volume grants BUILTIN\Users rights that are not ours to worry
|
||||||
|
// about.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t))
|
||||||
|
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write access held by an account that cannot answer the UAC prompt means that
|
||||||
|
// account decides what runs behind it, whoever the ACE names. The trustees that
|
||||||
|
// must not have it cannot be listed, so the check names the ones that may.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wellKnown windows.WELL_KNOWN_SID_TYPE
|
||||||
|
}{
|
||||||
|
{name: "everyone", wellKnown: windows.WinWorldSid},
|
||||||
|
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
|
||||||
|
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
|
||||||
|
// A service account, which no denylist of the obvious groups would name
|
||||||
|
// and which cannot elevate any more than Everyone can.
|
||||||
|
{name: "local service", wellKnown: windows.WinLocalServiceSid},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := writeExecutable(t)
|
||||||
|
grantWrite(t, path, tt.wellKnown)
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path),
|
||||||
|
"write access for %s must be refused", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The masks are the policy: on a file any write reaches its contents, while on a
|
||||||
|
// directory only deleting or taking over an entry reaches something already
|
||||||
|
// there. Adding an entry does not, which is why the walk survives a volume root.
|
||||||
|
func TestWriteAccessMasks(t *testing.T) {
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
|
||||||
|
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
|
||||||
|
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
|
||||||
|
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowACE(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aceType uint8
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
|
||||||
|
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
|
||||||
|
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
|
||||||
|
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
|
||||||
|
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
|
||||||
|
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
|
||||||
|
// access rather than granting it.
|
||||||
|
{name: "audit", aceType: 0x2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain file under the test's own directory, the shape
|
||||||
|
// trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantWrite replaces the file's DACL with one that grants a well-known trustee
|
||||||
|
// everything, keeping the test user's own access so the file stays deletable.
|
||||||
|
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
trustee, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
require.NoError(t, err, "build the trustee SID")
|
||||||
|
self, err := currentUserSID()
|
||||||
|
require.NoError(t, err, "read the test user's SID")
|
||||||
|
|
||||||
|
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
|
||||||
|
fullControl(self, windows.TRUSTEE_IS_USER),
|
||||||
|
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
|
||||||
|
}, nil)
|
||||||
|
require.NoError(t, err, "build the ACL")
|
||||||
|
|
||||||
|
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||||
|
nil, nil, acl, nil), "set the DACL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
|
||||||
|
return windows.EXPLICIT_ACCESS{
|
||||||
|
AccessPermissions: windows.GENERIC_ALL,
|
||||||
|
AccessMode: windows.GRANT_ACCESS,
|
||||||
|
Trustee: windows.TRUSTEE{
|
||||||
|
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||||
|
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
|
||||||
|
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
}
|
}
|
||||||
e.stateManager.Start()
|
e.stateManager.Start()
|
||||||
|
|
||||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
dnsServer, err := e.newDnsServer()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read initial settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create dns server: %w", err)
|
return fmt.Errorf("create dns server: %w", err)
|
||||||
}
|
}
|
||||||
@@ -595,10 +590,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
WGInterface: e.wgInterface,
|
WGInterface: e.wgInterface,
|
||||||
StatusRecorder: e.statusRecorder,
|
StatusRecorder: e.statusRecorder,
|
||||||
RelayManager: e.relayManager,
|
RelayManager: e.relayManager,
|
||||||
InitialRoutes: initialRoutes,
|
|
||||||
StateManager: e.stateManager,
|
StateManager: e.stateManager,
|
||||||
DNSServer: dnsServer,
|
DNSServer: dnsServer,
|
||||||
DNSFeatureFlag: dnsFeatureFlag,
|
|
||||||
PeerStore: e.peerStore,
|
PeerStore: e.peerStore,
|
||||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||||
@@ -2102,42 +2095,6 @@ func (e *Engine) close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
|
||||||
if runtime.GOOS != "android" {
|
|
||||||
// nolint:nilnil
|
|
||||||
return nil, nil, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info := system.GetInfo(e.ctx)
|
|
||||||
info.SetFlags(
|
|
||||||
e.config.RosenpassEnabled,
|
|
||||||
e.config.RosenpassPermissive,
|
|
||||||
&e.config.ServerSSHAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
|
||||||
e.config.DisableServerRoutes,
|
|
||||||
e.config.DisableDNS,
|
|
||||||
e.config.DisableFirewall,
|
|
||||||
e.config.BlockLANAccess,
|
|
||||||
e.config.BlockInbound,
|
|
||||||
e.config.DisableIPv6,
|
|
||||||
e.config.SyncMessageVersion,
|
|
||||||
e.config.EnableSSHRoot,
|
|
||||||
e.config.EnableSSHSFTP,
|
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
|
||||||
e.config.EnableSSHRemotePortForwarding,
|
|
||||||
e.config.DisableSSHAuth,
|
|
||||||
)
|
|
||||||
|
|
||||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, false, err
|
|
||||||
}
|
|
||||||
routes := toRoutes(netMap.GetRoutes())
|
|
||||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
|
||||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
|
||||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||||
transportNet, err := e.newStdNet()
|
transportNet, err := e.newStdNet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
|||||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "android":
|
case "android":
|
||||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||||
case "ios":
|
case "ios":
|
||||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||||
if e.config.WgAddr.HasIPv6() {
|
if e.config.WgAddr.HasIPv6() {
|
||||||
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||||
// due to tests where we are using a mocked version of the DNS server
|
// due to tests where we are using a mocked version of the DNS server
|
||||||
if e.dnsServer != nil {
|
if e.dnsServer != nil {
|
||||||
return e.dnsServer, nil
|
return e.dnsServer, nil
|
||||||
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
|||||||
e.ctx,
|
e.ctx,
|
||||||
e.wgInterface,
|
e.wgInterface,
|
||||||
e.mobileDep.HostDNSAddresses,
|
e.mobileDep.HostDNSAddresses,
|
||||||
*dnsConfig,
|
nbdns.Config{},
|
||||||
e.mobileDep.NetworkChangeListener,
|
e.mobileDep.NetworkChangeListener,
|
||||||
e.statusRecorder,
|
e.statusRecorder,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
|
|||||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
func (e *Engine) TunSettings() ([]string, []string) {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
routeManager := e.routeManager
|
||||||
|
dnsServer := e.dnsServer
|
||||||
|
e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
|
var routes []string
|
||||||
|
if routeManager != nil {
|
||||||
|
routes = routeManager.CurrentRouteRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchDomains []string
|
||||||
|
if dnsServer != nil {
|
||||||
|
searchDomains = dnsServer.SearchDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes, searchDomains
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
|
|||||||
return selfIdentity, true
|
return selfIdentity, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The values PrivilegedActorKey returns.
|
||||||
|
const (
|
||||||
|
ActorKeyAdministrator = "administrator"
|
||||||
|
ActorKeyRoot = "root"
|
||||||
|
)
|
||||||
|
|
||||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||||
// in messages shown to the user.
|
// in messages shown to the user.
|
||||||
func PrivilegedActor() string {
|
func PrivilegedActor() string {
|
||||||
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
|
|||||||
return "root"
|
return "root"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivilegedActorKey identifies that principal without wording it, for a client
|
||||||
|
// that writes its own message in the user's language. The words PrivilegedActor
|
||||||
|
// returns are English, and a translated sentence cannot borrow them.
|
||||||
|
func PrivilegedActorKey() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return ActorKeyAdministrator
|
||||||
|
}
|
||||||
|
return ActorKeyRoot
|
||||||
|
}
|
||||||
|
|
||||||
// ElevatedCommand renders a command so that running it grants the privileges the
|
// 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
|
// 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
|
// returned unchanged and the user is expected to run it from an elevated
|
||||||
|
|||||||
@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
|||||||
return &state, nil
|
return &state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
// 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 {
|
||||||
configDir, err := getConfigDir()
|
configDir, err := getConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get config directory: %w", err)
|
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()
|
activeProf, err := pm.GetActiveProfile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNoActiveProfile) {
|
if errors.Is(err, ErrNoActiveProfile) {
|
||||||
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|||||||
return fmt.Errorf("get active profile: %w", err)
|
return fmt.Errorf("get active profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id := activeProf.ID
|
return pm.SetProfileState(activeProf.ID, state)
|
||||||
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
|
// 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
|
// internalDnatFw checks if the firewall supports internal DNAT
|
||||||
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||||
if d.firewall == nil || runtime.GOOS != "android" {
|
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
fw, ok := d.firewall.(internalDNATer)
|
fw, ok := d.firewall.(internalDNATer)
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
@@ -62,7 +61,7 @@ type Manager interface {
|
|||||||
GetActiveClientRoutes() route.HAMap
|
GetActiveClientRoutes() route.HAMap
|
||||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||||
InitialRouteRange() []string
|
CurrentRouteRange() []string
|
||||||
SetFirewall(firewall.Manager) error
|
SetFirewall(firewall.Manager) error
|
||||||
SetDNSForwarderPort(port uint16)
|
SetDNSForwarderPort(port uint16)
|
||||||
ReconcilePeerAllowedIPs(peerKey string) error
|
ReconcilePeerAllowedIPs(peerKey string) error
|
||||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
|||||||
WGInterface iface.WGIface
|
WGInterface iface.WGIface
|
||||||
StatusRecorder *peer.Status
|
StatusRecorder *peer.Status
|
||||||
RelayManager *relayClient.Manager
|
RelayManager *relayClient.Manager
|
||||||
InitialRoutes []*route.Route
|
|
||||||
StateManager *statemanager.Manager
|
StateManager *statemanager.Manager
|
||||||
DNSServer dns.Server
|
DNSServer dns.Server
|
||||||
DNSFeatureFlag bool
|
|
||||||
PeerStore *peerstore.Store
|
PeerStore *peerstore.Store
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
DisableServerRoutes bool
|
DisableServerRoutes bool
|
||||||
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
|||||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||||
dm.setupRefCounters(useNoop)
|
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
|
return dm
|
||||||
}
|
}
|
||||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
|
||||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
|
||||||
|
|
||||||
routesForComparison := slices.Clone(cr)
|
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||||
|
m.fakeIPManager = fakeip.NewManager()
|
||||||
if config.DNSFeatureFlag {
|
m.notifier.NotifyRouteChange()
|
||||||
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) {
|
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||||
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
|
|||||||
|
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
if !m.disableClientRoutes {
|
if !m.disableClientRoutes {
|
||||||
|
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||||
|
m.enableFakeIPRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
// Update route selector based on management server's isSelected status
|
// Update route selector based on management server's isSelected status
|
||||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||||
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
|||||||
m.notifier.SetListener(listener)
|
m.notifier.SetListener(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||||
func (m *DefaultManager) InitialRouteRange() []string {
|
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||||
return m.notifier.GetInitialRouteRanges()
|
m.mux.Lock()
|
||||||
|
defer m.mux.Unlock()
|
||||||
|
|
||||||
|
if m.disableClientRoutes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||||
|
var nets []string
|
||||||
|
for _, routes := range filtered {
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.IsDynamic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nets = append(nets, r.NetString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.fakeIPManager != nil {
|
||||||
|
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(nets)
|
||||||
|
return nets
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRouteSelector returns the route selector
|
// GetRouteSelector returns the route selector
|
||||||
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
|||||||
return newServerRoutesMap, newClientRoutesIDMap
|
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 {
|
func isRouteSupported(route *route.Route) bool {
|
||||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||||
func (m *MockManager) InitialRouteRange() []string {
|
func (m *MockManager) CurrentRouteRange() []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
@@ -14,12 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
initialRoutes []*route.Route
|
mu sync.Mutex
|
||||||
currentRoutes []*route.Route
|
|
||||||
fakeIPRoutes []*route.Route
|
|
||||||
|
|
||||||
listener listener.NetworkChangeListener
|
// currentRoutes is the last announced route set. It exists only to
|
||||||
listenerMux sync.Mutex
|
// suppress noise: without it every network map sync would trigger the
|
||||||
|
// Java side, even when the routes did not change. The actual TUN route
|
||||||
|
// state is owned by the route manager and pulled from there.
|
||||||
|
currentRoutes []*route.Route
|
||||||
|
|
||||||
|
listener listener.NetworkChangeListener
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotifier() *Notifier {
|
func NewNotifier() *Notifier {
|
||||||
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||||
n.listenerMux.Lock()
|
n.mu.Lock()
|
||||||
defer n.listenerMux.Unlock()
|
defer n.mu.Unlock()
|
||||||
n.listener = listener
|
n.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
n.mu.Lock()
|
||||||
n.initialRoutes = filterStatic(initialRoutes)
|
defer n.mu.Unlock()
|
||||||
n.currentRoutes = filterStatic(routesForComparison)
|
n.notifyLocked()
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||||
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
n.currentRoutes = newRoutes
|
n.currentRoutes = newRoutes
|
||||||
n.notify()
|
n.notifyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||||
// Not used on Android
|
// Not used on Android
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) notify() {
|
func (n *Notifier) notifyLocked() {
|
||||||
n.listenerMux.Lock()
|
|
||||||
defer n.listenerMux.Unlock()
|
|
||||||
if n.listener == nil {
|
if n.listener == nil {
|
||||||
return
|
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 filterStatic(routes []*route.Route) []*route.Route {
|
func (n *Notifier) Close() {
|
||||||
out := make([]*route.Route, 0, len(routes))
|
// unused
|
||||||
for _, r := range routes {
|
|
||||||
if !r.IsDynamic() {
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
func routesToStrings(routes []*route.Route) []string {
|
||||||
nets := make([]string, 0, len(routes))
|
nets := make([]string, 0, len(routes))
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
nets = append(nets, r.NetString())
|
nets = append(nets, r.NetString())
|
||||||
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
|||||||
return nets
|
return nets
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
as := routesToStrings(a)
|
||||||
return strings.Compare(x.NetString(), y.NetString())
|
bs := routesToStrings(b)
|
||||||
})
|
sort.Strings(as)
|
||||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
sort.Strings(bs)
|
||||||
return strings.Compare(x.NetString(), y.NetString())
|
return !slices.Equal(as, bs)
|
||||||
})
|
|
||||||
|
|
||||||
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,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
|||||||
n.listener = listener
|
n.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
// iOS doesn't care about initial routes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
|
||||||
// Not used on iOS
|
// Not used on iOS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
|||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
// Not used on non-mobile platforms
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
|
||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
|||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) Close() {
|
func (n *Notifier) Close() {
|
||||||
// unused
|
// unused
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
|||||||
func (u *Installer) startUIAsUser() error {
|
func (u *Installer) startUIAsUser() error {
|
||||||
log.Infof("starting netbird-ui: %s", uiBinary)
|
log.Infof("starting netbird-ui: %s", uiBinary)
|
||||||
|
|
||||||
// Get the current console user
|
username, err := consoleUser()
|
||||||
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get console user: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
username := strings.TrimSpace(string(output))
|
|
||||||
if username == "" || username == "root" {
|
|
||||||
return fmt.Errorf("no active user session found")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("starting UI for user: %s", username)
|
|
||||||
|
|
||||||
// Get user's UID
|
|
||||||
userInfo, err := user.Lookup(username)
|
userInfo, err := user.Lookup(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to lookup user %s: %w", username, err)
|
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the UI process as the console user using launchctl
|
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
|
||||||
// This ensures the app runs in the user's context with proper GUI access
|
|
||||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
|
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
|
||||||
log.Infof("launchCmd: %s", launchCmd.String())
|
log.Infof("launchCmd: %s", launchCmd.String())
|
||||||
// Set the user's home directory for proper macOS app behavior
|
|
||||||
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
|
|
||||||
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
|
|
||||||
|
|
||||||
if err := launchCmd.Start(); err != nil {
|
if err := launchCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to start UI process: %w", err)
|
return fmt.Errorf("run UI launch: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Release the process so it can run independently
|
|
||||||
if err := launchCmd.Process.Release(); err != nil {
|
|
||||||
log.Warnf("failed to release UI process: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("netbird-ui started successfully for user %s", username)
|
log.Infof("netbird-ui started successfully for user %s", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consoleUser() (string, error) {
|
||||||
|
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get console user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(string(output))
|
||||||
|
switch username {
|
||||||
|
case "", "root", "loginwindow", "_mbsetupuser":
|
||||||
|
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return username, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
||||||
log.Infof("installing pkg file: %s", path)
|
log.Infof("installing pkg file: %s", path)
|
||||||
|
|
||||||
|
|||||||
@@ -158,13 +158,19 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
|||||||
defer c.ctxCancel()
|
defer c.ctxCancel()
|
||||||
c.ctxCancelLock.Unlock()
|
c.ctxCancelLock.Unlock()
|
||||||
|
|
||||||
auth := NewAuthWithConfig(ctx, cfg)
|
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
|
||||||
err = auth.LoginSync()
|
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
|
||||||
if err != nil {
|
// this point only duplicated it — costing two extra Login RPCs (IsLoginRequired +
|
||||||
return err
|
// Login) on every engine start, since IsLoginRequired is itself a full Login RPC.
|
||||||
}
|
//
|
||||||
|
// Auth failures still reach the caller through the engine path: loginToManagement
|
||||||
log.Infof("Auth successful")
|
// returns PermissionDenied, which marks the shared status recorder
|
||||||
|
// (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where
|
||||||
|
// IsLoginRequiredCached() reports login-required. The error is also returned out of Run().
|
||||||
|
//
|
||||||
|
// A pre-flight was also actively harmful when the server is unreachable: its 2-minute
|
||||||
|
// backoff blocked the start and then reported "login required" for what was really a
|
||||||
|
// timeout. The engine instead keeps retrying and recovers when the server returns.
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
c.onHostDnsFn = func([]string) {}
|
c.onHostDnsFn = func([]string) {}
|
||||||
|
|||||||
@@ -222,17 +222,36 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic
|
|||||||
// LoginWithDeviceName performs interactive login with device authentication support
|
// LoginWithDeviceName performs interactive login with device authentication support
|
||||||
// The deviceName parameter allows specifying a custom device name (required for tvOS)
|
// The deviceName parameter allows specifying a custom device name (required for tvOS)
|
||||||
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
|
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
|
||||||
|
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the
|
||||||
|
// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow.
|
||||||
|
//
|
||||||
|
// IsLoginRequired() is itself a full Login RPC against the management server, so when the
|
||||||
|
// caller has ALREADY established that login is required it is a pure duplicate. On iOS the
|
||||||
|
// main app decides to show the browser based on its own isLoginRequired() check and then
|
||||||
|
// calls straight into this method, so re-asking the server would add another Login RPC to
|
||||||
|
// every interactive login.
|
||||||
|
//
|
||||||
|
// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login
|
||||||
|
// must still be possible; use this when the browser is going to be shown regardless.
|
||||||
|
func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
|
||||||
|
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) {
|
||||||
if resultListener == nil {
|
if resultListener == nil {
|
||||||
log.Errorf("LoginWithDeviceName: resultListener is nil")
|
log.Errorf("startLogin: resultListener is nil")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if urlOpener == nil {
|
if urlOpener == nil {
|
||||||
log.Errorf("LoginWithDeviceName: urlOpener is nil")
|
log.Errorf("startLogin: urlOpener is nil")
|
||||||
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
|
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
err := a.login(urlOpener, forceDeviceAuth, deviceName)
|
err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resultListener.OnError(err)
|
resultListener.OnError(err)
|
||||||
} else {
|
} else {
|
||||||
@@ -241,7 +260,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error {
|
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
|
||||||
// Create context with device name if provided
|
// Create context with device name if provided
|
||||||
ctx := a.ctx
|
ctx := a.ctx
|
||||||
if deviceName != "" {
|
if deviceName != "" {
|
||||||
@@ -255,10 +274,13 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
|||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
// check if we need to generate JWT token
|
// check if we need to generate JWT token (skipped when the caller already knows)
|
||||||
needsLogin, err := authClient.IsLoginRequired(ctx)
|
needsLogin := true
|
||||||
if err != nil {
|
if !skipLoginCheck {
|
||||||
return fmt.Errorf("failed to check login requirement: %v", err)
|
needsLogin, err = authClient.IsLoginRequired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check login requirement: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
|
|||||||
110
client/server/login_outcome_test.go
Normal file
110
client/server/login_outcome_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A login that never reached Management is not a decision about the peer's
|
||||||
|
// credentials, so it must come back as a retryable error rather than an SSO
|
||||||
|
// prompt: the user cannot finish a browser login while Management is down, and
|
||||||
|
// the CLI's own backoff resolves the outage on its own once the daemon reports
|
||||||
|
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
|
||||||
|
// because Management happened to be restarting when the daemon dialed it.
|
||||||
|
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
|
||||||
|
s, _, _, username, _ := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
|
||||||
|
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||||
|
attempts := 0
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
attempts++
|
||||||
|
return false, unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
|
||||||
|
require.Nil(t, resp, "a failed login must not answer with a login response")
|
||||||
|
require.Equal(t, 1, attempts)
|
||||||
|
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
|
||||||
|
|
||||||
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"a peer that could not reach Management is not waiting on a login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counterpart: Management refusing the peer's credentials is a decision, and
|
||||||
|
// the SSO flow still has to start for it. The profile carries an unusable
|
||||||
|
// private key so the flow setup fails immediately instead of dialing, which is
|
||||||
|
// enough to show the branch was entered — the refusal itself is never what comes
|
||||||
|
// back out.
|
||||||
|
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
||||||
|
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
breakProfilePrivateKey(t, cfgPath)
|
||||||
|
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, stateErr)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"the SSO flow setup was never reached with the broken key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
|
||||||
|
s, _, _, username, _ := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysTried []string
|
||||||
|
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
|
||||||
|
keysTried = append(keysTried, setupKey)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
|
||||||
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
|
||||||
|
require.NoError(t, err, "the probe's outcome leaked out as the login result")
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
|
||||||
|
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
|
||||||
|
|
||||||
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, internal.StatusIdle, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||||
|
// one, which makes any attempt to build a Management client fail on the spot.
|
||||||
|
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||||
|
cfg["PrivateKey"] = "not-a-key"
|
||||||
|
|
||||||
|
patched, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
|
||||||
|
}
|
||||||
@@ -135,6 +135,13 @@ type Server struct {
|
|||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtCache
|
||||||
|
|
||||||
|
// loginAttemptFn stands in for the Management login round trip. Tests set
|
||||||
|
// it to drive the login outcomes that need a server on the other end;
|
||||||
|
// production leaves it nil, and every login goes through loginAttempt.
|
||||||
|
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||||
|
|
||||||
|
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthAuthFlow struct {
|
type oauthAuthFlow struct {
|
||||||
@@ -370,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
// attemptLogin runs a login round trip against Management, or the stand-in a
|
||||||
|
// test installed in place of it.
|
||||||
|
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
|
if s.loginAttemptFn != nil {
|
||||||
|
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
return s.loginAttempt(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
|
||||||
|
if s.isLoginRequiredFn != nil {
|
||||||
|
return s.isLoginRequiredFn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create auth client: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
|
return authClient.IsLoginRequired(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginAttempt attempts to login using the provided information. It returns
|
||||||
|
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||||
|
// StatusLoginFailed for every other failure, so callers can tell an
|
||||||
|
// authentication decision apart from a login that never got made.
|
||||||
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -623,7 +657,19 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
s.config = config
|
s.config = config
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
// A probe that errors leaves the login undecided: Management unreachable, a
|
||||||
|
// restart mid-request, an internal error. Those are returned for the caller
|
||||||
|
// to retry, because turning them into an SSO prompt asks the user to solve
|
||||||
|
// something that is not theirs to solve, and a browser login cannot succeed
|
||||||
|
// while Management is unreachable anyway. Only Management refusing the
|
||||||
|
// peer's key is a decision, and IsLoginRequired reports that as
|
||||||
|
// needsLogin=true rather than an error.
|
||||||
|
needsLogin, err := s.isLoginRequired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
state.Set(internal.StatusLoginFailed)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !needsLogin {
|
||||||
state.Set(internal.StatusIdle)
|
state.Set(internal.StatusIdle)
|
||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -684,7 +730,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
// which returns NeedsLogin and parks on the browser leg.
|
// which returns NeedsLogin and parks on the browser leg.
|
||||||
state.Set(internal.StatusConnecting)
|
state.Set(internal.StatusConnecting)
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -839,7 +885,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
s.oauthAuthFlow.expiresAt = time.Now()
|
s.oauthAuthFlow.expiresAt = time.Now()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1769,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
|
|||||||
if connectClient == nil {
|
if connectClient == nil {
|
||||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||||
}
|
}
|
||||||
|
if connectClient.Engine() == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
|
||||||
|
}
|
||||||
|
|
||||||
hint := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=netbird-ui
|
Name=NetBird
|
||||||
|
Comment=NetBird desktop client
|
||||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
||||||
Icon=netbird-ui
|
Icon=netbird-ui
|
||||||
Categories=Development;
|
Categories=Utility;Network;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Keywords=wails
|
Keywords=netbird;vpn;wireguard;
|
||||||
Version=1.0
|
Version=1.0
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=Netbird
|
Name=NetBird
|
||||||
|
Comment=NetBird desktop client
|
||||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||||
Icon=netbird
|
Icon=netbird
|
||||||
Type=Application
|
Type=Application
|
||||||
|
|||||||
@@ -21,22 +21,31 @@ contents:
|
|||||||
dst: "/usr/local/bin/netbird-ui"
|
dst: "/usr/local/bin/netbird-ui"
|
||||||
- src: "./build/appicon.png"
|
- src: "./build/appicon.png"
|
||||||
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
|
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
|
||||||
|
# The name the polkit action's icon_name refers to, which the released packages
|
||||||
|
# install as /usr/share/pixmaps/netbird.png.
|
||||||
|
- src: "./build/appicon.png"
|
||||||
|
dst: "/usr/share/icons/hicolor/128x128/apps/netbird.png"
|
||||||
- src: "./build/linux/netbird-ui.desktop"
|
- src: "./build/linux/netbird-ui.desktop"
|
||||||
dst: "/usr/share/applications/netbird-ui.desktop"
|
dst: "/usr/share/applications/netbird-ui.desktop"
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog shows a
|
||||||
|
# raw command line.
|
||||||
|
- src: "./build/linux/polkit/io.netbird.settings.policy"
|
||||||
|
dst: "/usr/share/polkit-1/actions/io.netbird.settings.policy"
|
||||||
|
|
||||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||||
depends:
|
depends:
|
||||||
- libgtk-4-1
|
- libgtk-4-1 (>= 4.14)
|
||||||
- libwebkitgtk-6.0-4
|
- libwebkitgtk-6.0-4
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
|
|
||||||
# Distribution-specific overrides for different package formats
|
# Distribution-specific overrides for different package formats
|
||||||
overrides:
|
overrides:
|
||||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
|
||||||
rpm:
|
rpm:
|
||||||
depends:
|
depends:
|
||||||
- gtk4
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
- webkitgtk6.0
|
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
|
|
||||||
# Arch Linux packages
|
# Arch Linux packages
|
||||||
|
|||||||
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Names the action behind the elevation prompt the desktop app raises for an SSH
|
||||||
|
setting the daemon restricts to root; without it pkexec's generic dialog offers
|
||||||
|
the raw command line instead. The argv1 annotation keeps this wording to the
|
||||||
|
one-shot mode that applies those settings.
|
||||||
|
|
||||||
|
auth_admin rather than auth_admin_keep: each of these settings is its own grant
|
||||||
|
of shell access, so a credential cache would let a second, unasked-for change
|
||||||
|
ride along on the authorization given the first.
|
||||||
|
|
||||||
|
exec.path takes no wildcard and the binary's location depends on the package,
|
||||||
|
hence one action per path.
|
||||||
|
-->
|
||||||
|
<policyconfig>
|
||||||
|
<vendor>NetBird</vendor>
|
||||||
|
<vendor_url>https://netbird.io</vendor_url>
|
||||||
|
|
||||||
|
<action id="io.netbird.settings.apply-privileged">
|
||||||
|
<description>Change privileged NetBird settings</description>
|
||||||
|
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||||
|
<icon_name>netbird</icon_name>
|
||||||
|
<defaults>
|
||||||
|
<allow_any>auth_admin</allow_any>
|
||||||
|
<allow_inactive>auth_admin</allow_inactive>
|
||||||
|
<allow_active>auth_admin</allow_active>
|
||||||
|
</defaults>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action id="io.netbird.settings.apply-privileged-local">
|
||||||
|
<description>Change privileged NetBird settings</description>
|
||||||
|
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||||
|
<icon_name>netbird</icon_name>
|
||||||
|
<defaults>
|
||||||
|
<allow_any>auth_admin</allow_any>
|
||||||
|
<allow_inactive>auth_admin</allow_inactive>
|
||||||
|
<allow_active>auth_admin</allow_active>
|
||||||
|
</defaults>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||||
|
</action>
|
||||||
|
</policyconfig>
|
||||||
@@ -22,12 +22,18 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
|
|||||||
|
|
||||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||||
|
|
||||||
|
// GuardedField is a setting the daemon only accepts from root/administrator.
|
||||||
|
// Turning one on goes through saveGuardedField, which asks the operating system
|
||||||
|
// for the privileges rather than sending a request that would be refused.
|
||||||
|
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
|
||||||
|
|
||||||
type SettingsContextValue = {
|
type SettingsContextValue = {
|
||||||
config: Config;
|
config: Config;
|
||||||
guiVersion: string;
|
guiVersion: string;
|
||||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||||
|
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
|
||||||
saveNow: () => Promise<void>;
|
saveNow: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +69,12 @@ const useSettingsState = () => {
|
|||||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||||
|
// Set when the daemon's config changed while a save was pending, so the read
|
||||||
|
// that was skipped to protect the pending edit happens once it is through.
|
||||||
|
// Without it the form keeps values the daemon no longer has and the next save
|
||||||
|
// submits them, which for a guarded setting means asking the user to authorize
|
||||||
|
// a change they never made.
|
||||||
|
const reloadOwed = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadedRef.current = loaded;
|
loadedRef.current = loaded;
|
||||||
@@ -73,6 +85,7 @@ const useSettingsState = () => {
|
|||||||
// update the daemon then rejected.
|
// update the daemon then rejected.
|
||||||
const reload = useCallback(
|
const reload = useCallback(
|
||||||
async (profileName: string) => {
|
async (profileName: string) => {
|
||||||
|
reloadOwed.current = false;
|
||||||
try {
|
try {
|
||||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||||
setLoaded({ profileName, data });
|
setLoaded({ profileName, data });
|
||||||
@@ -94,7 +107,12 @@ const useSettingsState = () => {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (saveTimer.current) return;
|
// A pending edit outranks the daemon's copy until it is saved, so
|
||||||
|
// the read is owed rather than dropped: see reloadOwed.
|
||||||
|
if (saveTimer.current) {
|
||||||
|
reloadOwed.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoaded({ profileName: activeProfileId, data });
|
setLoaded({ profileName: activeProfileId, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled || !showError) return;
|
if (cancelled || !showError) return;
|
||||||
@@ -141,12 +159,17 @@ const useSettingsState = () => {
|
|||||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||||
try {
|
try {
|
||||||
await SettingsSvc.SetConfig({
|
const { declined } = await SettingsSvc.SetConfig({
|
||||||
...next,
|
...next,
|
||||||
...preSharedKeyWrite,
|
...preSharedKeyWrite,
|
||||||
profileName,
|
profileName,
|
||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
|
// The change needed authorization and the user said no, so the
|
||||||
|
// optimistic update is wrong. Nothing to report: they know.
|
||||||
|
if (declined || reloadOwed.current) {
|
||||||
|
await reload(profileName);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The optimistic update is wrong now: the daemon refused it
|
// The optimistic update is wrong now: the daemon refused it
|
||||||
// (a change that needs elevated privileges, an MDM-managed
|
// (a change that needs elevated privileges, an MDM-managed
|
||||||
@@ -206,6 +229,59 @@ const useSettingsState = () => {
|
|||||||
[loaded, save],
|
[loaded, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// saveGuardedField applies a setting the daemon restricts to
|
||||||
|
// root/administrator by having the Go side run the app again under the
|
||||||
|
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
|
||||||
|
// The prompt is the user's, so the call is made straight from their gesture
|
||||||
|
// and never from the debounce.
|
||||||
|
const saveGuardedField = useCallback(
|
||||||
|
async (k: GuardedField, v: boolean) => {
|
||||||
|
const cur = loadedRef.current;
|
||||||
|
if (!cur) return;
|
||||||
|
|
||||||
|
// Flush what the debounce still owes, before the optimistic update
|
||||||
|
// below joins it: a later save carrying the guarded value would be
|
||||||
|
// refused, and its error dialog would be the second one for a change
|
||||||
|
// the user already authorized.
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
await save(cur.profileName, cur.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: LoadedConfig = {
|
||||||
|
profileName: cur.profileName,
|
||||||
|
data: { ...cur.data, [k]: v },
|
||||||
|
};
|
||||||
|
loadedRef.current = next;
|
||||||
|
setLoaded(next);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await SettingsSvc.SetGuardedSettings({
|
||||||
|
profileName: cur.profileName,
|
||||||
|
username,
|
||||||
|
[k]: v,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// The daemon is authoritative either way, so re-read before
|
||||||
|
// reporting. A declined prompt is not an error and does not come
|
||||||
|
// through here at all; this is a prompt that could not be raised,
|
||||||
|
// which carries the command that would have done it.
|
||||||
|
await reload(cur.profileName);
|
||||||
|
await errorDialog({
|
||||||
|
Title: i18next.t("settings.error.saveTitle"),
|
||||||
|
Message: errorMessage(e),
|
||||||
|
Command: errorCommand(e),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Either the change went through or the user declined it. The daemon
|
||||||
|
// says which.
|
||||||
|
await reload(cur.profileName);
|
||||||
|
},
|
||||||
|
[username, save, reload],
|
||||||
|
);
|
||||||
|
|
||||||
const saveFields = useCallback(
|
const saveFields = useCallback(
|
||||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
@@ -225,15 +301,27 @@ const useSettingsState = () => {
|
|||||||
[loaded, save],
|
[loaded, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
return {
|
||||||
|
config: loaded?.data ?? null,
|
||||||
|
guiVersion,
|
||||||
|
setField,
|
||||||
|
saveField,
|
||||||
|
saveFields,
|
||||||
|
saveGuardedField,
|
||||||
|
saveNow,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||||
|
useSettingsState();
|
||||||
|
|
||||||
const value = useMemo<SettingsContextValue | null>(
|
const value = useMemo<SettingsContextValue | null>(
|
||||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
() =>
|
||||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
config
|
||||||
|
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||||
|
: null,
|
||||||
|
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Settings as SettingsSvc } from "@bindings/services";
|
import { Settings as SettingsSvc } from "@bindings/services";
|
||||||
import { Privilege } from "@bindings/services/models.js";
|
import { type Privilege } from "@bindings/services/models.js";
|
||||||
|
|
||||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
// 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
|
// restricts to root/administrator. It is answered in-process from our own token
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runSsoLogin(
|
async function runSsoLogin(
|
||||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
result: {
|
||||||
|
verificationUri: string;
|
||||||
|
verificationUriComplete: string;
|
||||||
|
userCode: string;
|
||||||
|
profileId: string;
|
||||||
|
},
|
||||||
state: SsoState,
|
state: SsoState,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -56,7 +61,7 @@ async function runSsoLogin(
|
|||||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||||
{ userCode: result.userCode, hostname: "" },
|
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||||
{ profileName: "", username: "" },
|
{ profileName: "", username: "" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
|||||||
import { SquareIcon } from "@/components/SquareIcon";
|
import { SquareIcon } from "@/components/SquareIcon";
|
||||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||||
import { formatRemaining } from "@/lib/formatters";
|
import { formatRemaining } from "@/lib/formatters";
|
||||||
|
|
||||||
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
|
|||||||
}
|
}
|
||||||
}, [busy, t]);
|
}, [busy, t]);
|
||||||
|
|
||||||
|
const authenticate = useCallback(async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||||
|
await WindowManager.CloseSessionExpiration();
|
||||||
|
} catch (e) {
|
||||||
|
setBusy(false);
|
||||||
|
await errorDialog({
|
||||||
|
Title: t("connect.error.loginTitle"),
|
||||||
|
Message: formatErrorMessage(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [busy, t]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
|
|||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
size={"md"}
|
size={"md"}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
onClick={stay}
|
onClick={expired ? authenticate : stay}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||||
@@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input";
|
|||||||
import { Label } from "@/components/typography/Label";
|
import { Label } from "@/components/typography/Label";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||||
import { Privilege } from "@bindings/services/models.js";
|
import type { Privilege } from "@bindings/services/models.js";
|
||||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
export function SettingsSSH() {
|
export function SettingsSSH() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config, setField } = useSettings();
|
const { config, setField, saveGuardedField } = useSettings();
|
||||||
const privilege = usePrivilege();
|
const privilege = usePrivilege();
|
||||||
|
// The field whose elevation prompt is currently up, if any. The prompt is
|
||||||
|
// modal to the operating system, not to us, so the guarded controls are held
|
||||||
|
// still meanwhile rather than allowed to stack a second one behind it.
|
||||||
|
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
|
||||||
const isSSHServerEnabled = config.serverSshAllowed;
|
const isSSHServerEnabled = config.serverSshAllowed;
|
||||||
|
|
||||||
|
const authorize = async (field: GuardedField, value: boolean) => {
|
||||||
|
setAuthorizing(field);
|
||||||
|
try {
|
||||||
|
await saveGuardedField(field, value);
|
||||||
|
} finally {
|
||||||
|
setAuthorizing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The daemon restricts only the direction that hands out shells from a process
|
// 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
|
// running as root: for all three settings that is switching the field on.
|
||||||
// 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.
|
// An unprivileged user gets that direction routed through the platform's
|
||||||
|
// elevation prompt where there is one to raise, and otherwise the old
|
||||||
|
// arrangement, where the control is either unavailable (it is off and only a
|
||||||
|
// privileged caller could turn it on) or a one-way switch (it is on, they may
|
||||||
|
// turn it off but not back on) with the command that does it.
|
||||||
//
|
//
|
||||||
// A null privilege means we could not determine it: leave the control alone
|
// 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
|
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||||
// this regardless, and a rejected save reports its own guidance.
|
// this regardless, and a rejected save reports its own guidance.
|
||||||
const guarded = (
|
const guarded = (
|
||||||
guardedDirectionActive: boolean,
|
field: GuardedField,
|
||||||
command: (p: Privilege) => string,
|
command: (p: Privilege) => string,
|
||||||
// inverted marks a control whose guarded direction is switching it off, so
|
// inverted marks a control whose guarded direction is switching it off, so
|
||||||
// the one-way warning has to read the other way round.
|
// the one-way warning has to read the other way round.
|
||||||
inverted = false,
|
inverted = false,
|
||||||
) => {
|
) => {
|
||||||
|
const plain = (value: boolean) => setField(field, value);
|
||||||
if (!privilege || privilege.privileged) {
|
if (!privilege || privilege.privileged) {
|
||||||
return { disabled: false, hint: undefined };
|
return { apply: plain, disabled: false, hint: undefined };
|
||||||
}
|
}
|
||||||
const hint = (
|
|
||||||
<PrivilegeHint
|
const guardedDirectionActive = config[field];
|
||||||
actor={privilege.actor}
|
const hint = (pending: boolean, command?: string) => (
|
||||||
command={command(privilege)}
|
<GuardedHint
|
||||||
|
actor={actorLabel(privilege, t)}
|
||||||
oneWay={guardedDirectionActive}
|
oneWay={guardedDirectionActive}
|
||||||
inverted={inverted}
|
inverted={inverted}
|
||||||
|
pending={pending}
|
||||||
|
command={command}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return { disabled: !guardedDirectionActive, hint };
|
|
||||||
|
if (privilege.canElevate) {
|
||||||
|
return {
|
||||||
|
// Switching off is ours to do; only switching on is authorized.
|
||||||
|
apply: (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
|
plain(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void authorize(field, value);
|
||||||
|
},
|
||||||
|
disabled: authorizing !== null,
|
||||||
|
hint: hint(authorizing === field),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
apply: plain,
|
||||||
|
disabled: !guardedDirectionActive,
|
||||||
|
hint: hint(false, command(privilege)),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
|
||||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
|
||||||
// Inverted control: the guarded direction is switching authentication off, so
|
// Inverted control: the guarded direction is switching authentication off, so
|
||||||
// it is the already-disabled state that is the one-way one.
|
// it is the already-disabled state that is the one-way one.
|
||||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
|
||||||
const jwtTtlId = useId();
|
const jwtTtlId = useId();
|
||||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||||
|
|
||||||
@@ -84,7 +125,7 @@ export function SettingsSSH() {
|
|||||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={config.serverSshAllowed}
|
value={config.serverSshAllowed}
|
||||||
onChange={(v) => setField("serverSshAllowed", v)}
|
onChange={sshServer.apply}
|
||||||
disabled={sshServer.disabled}
|
disabled={sshServer.disabled}
|
||||||
label={t("settings.ssh.server.label")}
|
label={t("settings.ssh.server.label")}
|
||||||
helpText={t("settings.ssh.server.help")}
|
helpText={t("settings.ssh.server.help")}
|
||||||
@@ -98,7 +139,7 @@ export function SettingsSSH() {
|
|||||||
>
|
>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={config.enableSshRoot}
|
value={config.enableSshRoot}
|
||||||
onChange={(v) => setField("enableSshRoot", v)}
|
onChange={sshRoot.apply}
|
||||||
disabled={sshRoot.disabled}
|
disabled={sshRoot.disabled}
|
||||||
label={t("settings.ssh.root.label")}
|
label={t("settings.ssh.root.label")}
|
||||||
helpText={t("settings.ssh.root.help")}
|
helpText={t("settings.ssh.root.help")}
|
||||||
@@ -130,7 +171,7 @@ export function SettingsSSH() {
|
|||||||
>
|
>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={!config.disableSshAuth}
|
value={!config.disableSshAuth}
|
||||||
onChange={(v) => setField("disableSshAuth", !v)}
|
onChange={(v) => sshAuth.apply(!v)}
|
||||||
disabled={sshAuth.disabled}
|
disabled={sshAuth.disabled}
|
||||||
label={t("settings.ssh.jwt.label")}
|
label={t("settings.ssh.jwt.label")}
|
||||||
helpText={t("settings.ssh.jwt.help")}
|
helpText={t("settings.ssh.jwt.help")}
|
||||||
@@ -163,41 +204,81 @@ export function SettingsSSH() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
// actorLabel names the principal the daemon requires, in the user's language. The
|
||||||
// guarded control, and offers the command that does it with the privileges the
|
// Go side reports which one it is rather than wording it, because "administrator
|
||||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
// privileges" is English and a translated sentence cannot borrow it.
|
||||||
// switching it back is the part that needs privileges.
|
function actorLabel(privilege: Privilege, t: TFunction): string {
|
||||||
function PrivilegeHint({
|
return privilege.actorKey === "administrator"
|
||||||
|
? t("settings.ssh.privilege.actorAdministrator")
|
||||||
|
: t("settings.ssh.privilege.actorRoot");
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardedHint is what a control the daemon guards says to an unprivileged user.
|
||||||
|
// There are three things worth saying, and it says at most one:
|
||||||
|
//
|
||||||
|
// - A prompt is open. Worth a line because it can take a few seconds to appear,
|
||||||
|
// long enough that a control which merely went inert would read as a hang.
|
||||||
|
// - The setting is in its guarded state already (oneWay), so the user may switch
|
||||||
|
// it back as they please and it is switching it away again that will ask. No
|
||||||
|
// command either way: the direction they can take is theirs to take.
|
||||||
|
// - Only a privileged caller can move it at all, and there is no prompt to
|
||||||
|
// raise: the command that does it belongs here, and nothing else will do.
|
||||||
|
//
|
||||||
|
// Which leaves the case of a control whose guarded direction is still ahead of the
|
||||||
|
// user and a prompt that can be raised for it: nothing to say, because clicking it
|
||||||
|
// raises the prompt and the prompt explains itself.
|
||||||
|
function GuardedHint({
|
||||||
actor,
|
actor,
|
||||||
command,
|
|
||||||
oneWay,
|
oneWay,
|
||||||
inverted,
|
inverted,
|
||||||
|
pending,
|
||||||
|
command,
|
||||||
}: {
|
}: {
|
||||||
actor: string;
|
actor: string;
|
||||||
command: string;
|
|
||||||
oneWay: boolean;
|
oneWay: boolean;
|
||||||
inverted: boolean;
|
inverted: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
command?: string;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
return <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
|
||||||
|
}
|
||||||
|
if (oneWay) {
|
||||||
|
return (
|
||||||
|
<HintBox>
|
||||||
|
<span>
|
||||||
|
{inverted
|
||||||
|
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||||
|
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||||
|
</span>
|
||||||
|
</HintBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
|
return (
|
||||||
|
<HintBox>
|
||||||
|
<span>{t("settings.ssh.privilege.hint", { 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>
|
||||||
|
</HintBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HintBox is the box a guarded control puts its explanation in, directly under the
|
||||||
|
// control it belongs to.
|
||||||
|
function HintBox({ children }: { children: ReactNode }): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
{children}
|
||||||
{!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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Alle sichtbaren Ressourcen umschalten"
|
"message": "Alle sichtbaren Ressourcen umschalten"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Einstellungsbereiche"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Zu Profil \"{name}\" wechseln?"
|
"message": "Zu Profil \"{name}\" wechseln?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Debug-Paket fehlgeschlagen"
|
"message": "Debug-Paket fehlgeschlagen"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Einstellungsbereiche"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Allgemein"
|
"message": "Allgemein"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Vorgang fehlgeschlagen."
|
"message": "Vorgang fehlgeschlagen."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "root-Rechte"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "Administratorrechte"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Warten auf Autorisierung…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1775,16 +1775,36 @@
|
|||||||
"message": "Operation failed.",
|
"message": "Operation failed.",
|
||||||
"description": "Generic fallback error message used when no specific error applies."
|
"description": "Generic fallback error message used when no specific error applies."
|
||||||
},
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
|
||||||
|
"description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "The change could not be applied with elevated privileges. Run this instead:",
|
||||||
|
"description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "root",
|
||||||
|
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "administrator privileges",
|
||||||
|
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
|
||||||
|
},
|
||||||
"settings.ssh.privilege.hint": {
|
"settings.ssh.privilege.hint": {
|
||||||
"message": "Requires {actor}. Run this instead:",
|
"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."
|
"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": {
|
"settings.ssh.privilege.oneWay": {
|
||||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
"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."
|
"description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
|
||||||
},
|
},
|
||||||
"settings.ssh.privilege.oneWayInverted": {
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
"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."
|
"description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Waiting for authorization…",
|
||||||
|
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Conmutar todos los recursos visibles"
|
"message": "Conmutar todos los recursos visibles"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Secciones de configuración"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "¿Cambiar el perfil a «{name}»?"
|
"message": "¿Cambiar el perfil a «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Error en el paquete de diagnóstico"
|
"message": "Error en el paquete de diagnóstico"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Secciones de configuración"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "General"
|
"message": "General"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "La operación falló."
|
"message": "La operación falló."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "privilegios de root"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "privilegios de administrador"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Requiere {actor}. Ejecute esto en su lugar:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Esperando la autorización…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Activer/désactiver toutes les ressources visibles"
|
"message": "Activer/désactiver toutes les ressources visibles"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Sections des paramètres"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Basculer vers le profil « {name} » ?"
|
"message": "Basculer vers le profil « {name} » ?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Échec du lot de diagnostic"
|
"message": "Échec du lot de diagnostic"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Sections des paramètres"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Général"
|
"message": "Général"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "L’opération a échoué."
|
"message": "L’opération a échoué."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "les privilèges root"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "les privilèges administrateur"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "En attente de l’autorisation…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Összes látható erőforrás be/ki"
|
"message": "Összes látható erőforrás be/ki"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Beállítások szakaszai"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Hibakeresési csomag sikertelen"
|
"message": "Hibakeresési csomag sikertelen"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Beállítások szakaszai"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Általános"
|
"message": "Általános"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "A művelet meghiúsult."
|
"message": "A művelet meghiúsult."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "root jogosultság"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "rendszergazdai jogosultság"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Várakozás az engedélyezésre…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Attiva/disattiva tutte le risorse visibili"
|
"message": "Attiva/disattiva tutte le risorse visibili"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Sezioni delle impostazioni"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Passare al profilo «{name}»?"
|
"message": "Passare al profilo «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Pacchetto di debug non riuscito"
|
"message": "Pacchetto di debug non riuscito"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Sezioni delle impostazioni"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Generale"
|
"message": "Generale"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Operazione non riuscita."
|
"message": "Operazione non riuscita."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "i privilegi di root"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "i privilegi di amministratore"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Richiede {actor}. Esegua invece questo:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "In attesa dell'autorizzazione…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1304,6 +1304,9 @@
|
|||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
||||||
},
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "最新版をダウンロード"
|
||||||
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
||||||
},
|
},
|
||||||
@@ -1327,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "操作に失敗しました。"
|
"message": "操作に失敗しました。"
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "root 権限"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "管理者権限"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "承認を待っています…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Alternar todos os recursos visíveis"
|
"message": "Alternar todos os recursos visíveis"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Seções das configurações"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Alternar perfil para \"{name}\"?"
|
"message": "Alternar perfil para \"{name}\"?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Falha no pacote de depuração"
|
"message": "Falha no pacote de depuração"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Seções das configurações"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Geral"
|
"message": "Geral"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "A operação falhou."
|
"message": "A operação falhou."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "privilégios de root"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "privilégios de administrador"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Requer {actor}. Execute isto em vez disso:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Aguardando a autorização…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Переключить все видимые ресурсы"
|
"message": "Переключить все видимые ресурсы"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Разделы настроек"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Переключиться на профиль «{name}»?"
|
"message": "Переключиться на профиль «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Не удалось создать отладочный пакет"
|
"message": "Не удалось создать отладочный пакет"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Разделы настроек"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Общие"
|
"message": "Общие"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Не удалось выполнить операцию."
|
"message": "Не удалось выполнить операцию."
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "права root"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "права администратора"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "Требуются {actor}. Выполните вместо этого:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "Ожидание авторизации…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "切换所有可见资源"
|
"message": "切换所有可见资源"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "设置部分"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "切换到配置文件“{name}”?"
|
"message": "切换到配置文件“{name}”?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "创建调试包失败"
|
"message": "创建调试包失败"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "设置部分"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "常规"
|
"message": "常规"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "操作失败。"
|
"message": "操作失败。"
|
||||||
|
},
|
||||||
|
"error.elevation_unavailable": {
|
||||||
|
"message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
|
||||||
|
},
|
||||||
|
"error.elevation_failed": {
|
||||||
|
"message": "即使使用提升的权限也无法应用此更改。请改为运行:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorRoot": {
|
||||||
|
"message": "root 权限"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.actorAdministrator": {
|
||||||
|
"message": "管理员权限"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.hint": {
|
||||||
|
"message": "需要{actor}。请改为运行:"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWay": {
|
||||||
|
"message": "您可以关闭此项,但重新开启需要{actor}。"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
|
"message": "您可以开启此项,但再次关闭需要{actor}。"
|
||||||
|
},
|
||||||
|
"settings.ssh.privilege.authorizePending": {
|
||||||
|
"message": "正在等待授权…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/events"
|
"github.com/wailsapp/wails/v3/pkg/events"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||||
@@ -63,7 +63,7 @@ type registeredServices struct {
|
|||||||
profiles *services.Profiles
|
profiles *services.Profiles
|
||||||
update *services.Update
|
update *services.Update
|
||||||
daemonFeed *services.DaemonFeed
|
daemonFeed *services.DaemonFeed
|
||||||
notifier *notifications.NotificationService
|
notifier *Notifier
|
||||||
compat *services.Compat
|
compat *services.Compat
|
||||||
profileSwitcher *services.ProfileSwitcher
|
profileSwitcher *services.ProfileSwitcher
|
||||||
bundle *i18n.Bundle
|
bundle *i18n.Bundle
|
||||||
@@ -80,6 +80,14 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// The one-shot that applies the settings the daemon restricts to
|
||||||
|
// root/administrator, which this binary runs itself as under the platform's
|
||||||
|
// elevation prompt. Handled before anything GUI so no window, tray or
|
||||||
|
// single-instance lock is involved.
|
||||||
|
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
|
||||||
|
os.Exit(runPrivilegedSettings(os.Args[1:]))
|
||||||
|
}
|
||||||
|
|
||||||
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
||||||
conn := NewConn(daemonAddr)
|
conn := NewConn(daemonAddr)
|
||||||
|
|
||||||
@@ -102,7 +110,7 @@ func main() {
|
|||||||
updaterHolder := updater.NewHolder(app.Event)
|
updaterHolder := updater.NewHolder(app.Event)
|
||||||
update := services.NewUpdate(conn, updaterHolder)
|
update := services.NewUpdate(conn, updaterHolder)
|
||||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||||
notifier := notifications.New()
|
notifier := newNotifier()
|
||||||
compat := services.NewCompat(conn)
|
compat := services.NewCompat(conn)
|
||||||
// macOS shows no toast until permission is requested. Run it after
|
// macOS shows no toast until permission is requested. Run it after
|
||||||
// ApplicationStarted so the notifier's Startup has initialised the
|
// ApplicationStarted so the notifier's Startup has initialised the
|
||||||
@@ -210,7 +218,7 @@ func main() {
|
|||||||
// requestNotificationAuthorization prompts for macOS notification permission.
|
// requestNotificationAuthorization prompts for macOS notification permission.
|
||||||
// The request blocks until the user responds (up to 3 minutes), so callers run
|
// The request blocks until the user responds (up to 3 minutes), so callers run
|
||||||
// it in a goroutine. No-op on Linux/Windows.
|
// it in a goroutine. No-op on Linux/Windows.
|
||||||
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
func requestNotificationAuthorization(notifier *Notifier) {
|
||||||
authorized, err := notifier.CheckNotificationAuthorization()
|
authorized, err := notifier.CheckNotificationAuthorization()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Debugf("check notification authorization: %v", err)
|
logrus.Debugf("check notification authorization: %v", err)
|
||||||
|
|||||||
101
client/ui/notifier.go
Normal file
101
client/ui/notifier.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotificationsUnavailable = errors.New("notifications unavailable")
|
||||||
|
|
||||||
|
// Notifier wraps the Wails notification service so an unavailable backend
|
||||||
|
// disables notifications instead of aborting the app. Startup fails for
|
||||||
|
// environment reasons (a bare unbundled binary on macOS has no bundle
|
||||||
|
// identifier, a headless Linux session has no D-Bus session bus), and Wails
|
||||||
|
// treats a service startup error as fatal. After a failed startup every call
|
||||||
|
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
|
||||||
|
// identifier raises an Objective-C exception that recover() cannot catch.
|
||||||
|
type Notifier struct {
|
||||||
|
inner *notifications.NotificationService
|
||||||
|
available atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNotifier() *Notifier {
|
||||||
|
return &Notifier{inner: notifications.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName implements the Wails service-name hook for startup logs.
|
||||||
|
func (n *Notifier) ServiceName() string {
|
||||||
|
return n.inner.ServiceName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceStartup starts the platform notifier, downgrading failure to a
|
||||||
|
// warning so the app keeps running without notifications.
|
||||||
|
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
if err := n.inner.ServiceStartup(ctx, options); err != nil {
|
||||||
|
log.Warnf("notifications disabled: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.available.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) ServiceShutdown() error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.ServiceShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return false, errNotificationsUnavailable
|
||||||
|
}
|
||||||
|
return n.inner.CheckNotificationAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return false, errNotificationsUnavailable
|
||||||
|
}
|
||||||
|
return n.inner.RequestNotificationAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotification delivers a notification, silently dropping it when the
|
||||||
|
// backend never started (notifications are best-effort everywhere).
|
||||||
|
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.SendNotification(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.SendNotificationWithActions(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.RegisterNotificationCategory(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnNotificationResponse registers the response callback. Pure Go state, so
|
||||||
|
// it is safe (and simply inert) when the backend never started.
|
||||||
|
//
|
||||||
|
//wails:ignore
|
||||||
|
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
|
||||||
|
n.inner.OnNotificationResponse(callback)
|
||||||
|
}
|
||||||
@@ -246,6 +246,7 @@ func (s *Store) ExistedAtLoad() bool {
|
|||||||
func (s *Store) load() error {
|
func (s *Store) load() error {
|
||||||
if _, err := os.Stat(s.path); err != nil {
|
if _, err := os.Stat(s.path); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("stat preferences: %w", err)
|
return fmt.Errorf("stat preferences: %w", err)
|
||||||
|
|||||||
27
client/ui/privileged_settings.go
Normal file
27
client/ui/privileged_settings.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/client/ui/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one-shot mode this binary runs itself in, elevated, to apply the settings the
|
||||||
|
// daemon restricts to root/administrator. It is handled before anything GUI, so no
|
||||||
|
// window, tray or single-instance lock is involved.
|
||||||
|
//
|
||||||
|
// Only the wiring is here: what the mode accepts and does lives beside the code
|
||||||
|
// that asks for it, in services.RunPrivilegedSettings, so the settings it will
|
||||||
|
// apply are declared once. There is nothing privileged about the mode itself; it
|
||||||
|
// sends the same request the frontend would have sent, and the daemon authorizes it
|
||||||
|
// from the identity the kernel reports on the control channel exactly as it does
|
||||||
|
// for `sudo netbird up`.
|
||||||
|
func runPrivilegedSettings(args []string) int {
|
||||||
|
return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
|
||||||
|
if addr == "" {
|
||||||
|
addr = DaemonAddr()
|
||||||
|
}
|
||||||
|
return NewConn(addr).Client()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -33,12 +33,21 @@ type LoginResult struct {
|
|||||||
UserCode string `json:"userCode"`
|
UserCode string `json:"userCode"`
|
||||||
VerificationURI string `json:"verificationUri"`
|
VerificationURI string `json:"verificationUri"`
|
||||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
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.
|
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||||
type WaitSSOParams struct {
|
type WaitSSOParams struct {
|
||||||
UserCode string `json:"userCode"`
|
UserCode string `json:"userCode"`
|
||||||
Hostname string `json:"hostname"`
|
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.
|
// UpParams selects the profile to bring up.
|
||||||
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
|||||||
// Fall back to the daemon's active profile and the current OS user.
|
// Fall back to the daemon's active profile and the current OS user.
|
||||||
profileName := p.ProfileName
|
profileName := p.ProfileName
|
||||||
username := p.Username
|
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 profileName == "" {
|
||||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||||
// Address the active profile by ID (the daemon resolves it as a
|
// Address the active profile by ID (the daemon resolves it as a
|
||||||
// handle); names can collide, the ID cannot.
|
// handle); names can collide, the ID cannot.
|
||||||
profileName = active.GetId()
|
profileName = active.GetId()
|
||||||
|
profileID = profileName
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = active.GetUsername()
|
username = active.GetUsername()
|
||||||
}
|
}
|
||||||
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
|||||||
UserCode: resp.GetUserCode(),
|
UserCode: resp.GetUserCode(),
|
||||||
VerificationURI: resp.GetVerificationURI(),
|
VerificationURI: resp.GetVerificationURI(),
|
||||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||||
|
ProfileID: profileID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
|||||||
return "", s.classifyDaemonError(err)
|
return "", s.classifyDaemonError(err)
|
||||||
}
|
}
|
||||||
log.Infof("SSO login completed, daemon reported success")
|
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
|
return resp.GetEmail(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
231
client/ui/services/guarded.go
Normal file
231
client/ui/services/guarded.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The command line of the one-shot mode this binary runs itself in, elevated, to
|
||||||
|
// apply a setting the daemon restricts to root/administrator. The setting flags
|
||||||
|
// spell the same words as `netbird up`, so the command a user is shown and what
|
||||||
|
// runs behind the prompt read alike. Parsed in oneshot.go.
|
||||||
|
const (
|
||||||
|
FlagApplyPrivilegedSettings = "apply-privileged-settings"
|
||||||
|
FlagDaemonAddr = "daemon-addr"
|
||||||
|
FlagProfile = "profile"
|
||||||
|
FlagUser = "user"
|
||||||
|
FlagLogLevel = "log-level"
|
||||||
|
FlagManagementURL = "management-url"
|
||||||
|
FlagAllowServerSSH = "allow-server-ssh"
|
||||||
|
FlagEnableSSHRoot = "enable-ssh-root"
|
||||||
|
FlagDisableSSHAuth = "disable-ssh-auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error codes for the ways asking for privileges can fail.
|
||||||
|
const (
|
||||||
|
CodeElevationUnavailable = "elevation_unavailable"
|
||||||
|
CodeElevationFailed = "elevation_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
|
||||||
|
// dialog nobody answers does not leave its control disabled for the session. Long
|
||||||
|
// enough to find a password manager, and no shorter than the platforms' own prompt
|
||||||
|
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
|
||||||
|
//
|
||||||
|
// It always ends our waiting, and not always the prompt: Security.framework offers
|
||||||
|
// no way to withdraw a request, so on macOS the system's own timeout is what closes
|
||||||
|
// the dialog.
|
||||||
|
const elevationTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// elevator raises the platform's privilege prompt and runs the change behind it.
|
||||||
|
// An interface so tests can answer without a prompt.
|
||||||
|
type elevator interface {
|
||||||
|
// Run runs this binary again, elevated, with the given arguments.
|
||||||
|
Run(ctx context.Context, args ...string) error
|
||||||
|
// Available reports whether there is a prompt to raise on this host at all.
|
||||||
|
Available() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// osElevator is the real thing: see the elevate package.
|
||||||
|
type osElevator struct{}
|
||||||
|
|
||||||
|
func (osElevator) Run(ctx context.Context, args ...string) error {
|
||||||
|
return elevate.Run(ctx, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (osElevator) Available() bool {
|
||||||
|
return elevate.Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveOutcome reports what became of a change that needed authorization.
|
||||||
|
//
|
||||||
|
// A declined prompt is a result, not an error: the user was asked and said no, so
|
||||||
|
// nothing was applied and nothing went wrong. Reporting it as an error would have
|
||||||
|
// every cancelled prompt logged as one.
|
||||||
|
type SaveOutcome struct {
|
||||||
|
// Declined is set when the user dismissed the authorization prompt, or was
|
||||||
|
// refused by policy. Nothing was changed.
|
||||||
|
Declined bool `json:"declined"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardedSettings is the subset of the config the daemon restricts to
|
||||||
|
// root/administrator. Only the fields that are set are changed: a nil pointer, or
|
||||||
|
// an empty management URL, leaves that setting alone.
|
||||||
|
//
|
||||||
|
// The management URL is in here because pointing a host with the SSH server
|
||||||
|
// running at another management identity hands the decision of who may open a
|
||||||
|
// shell on it to whoever runs that server, which is the same power as enabling
|
||||||
|
// the SSH server in the first place.
|
||||||
|
type GuardedSettings struct {
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ManagementURL string `json:"managementUrl,omitempty"`
|
||||||
|
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
|
||||||
|
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
|
||||||
|
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedSetting is one setting to change, in the two spellings this needs: the
|
||||||
|
// one-shot's own flag, and the `netbird up` flag that does the same thing from a
|
||||||
|
// terminal, for when there is no prompt to raise.
|
||||||
|
type guardedSetting struct {
|
||||||
|
arg string
|
||||||
|
flag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGuardedSettings applies settings the daemon refuses from an unprivileged
|
||||||
|
// caller, by having the operating system run this binary again, elevated, to send
|
||||||
|
// the same request the frontend would have sent itself.
|
||||||
|
//
|
||||||
|
// The user authorizes it at the platform's own prompt: the UAC consent dialog,
|
||||||
|
// the macOS authentication dialog, or the polkit agent's. Any credentials are the
|
||||||
|
// operating system's business; NetBird neither sees nor asks for them. Nothing
|
||||||
|
// about the daemon's rules changes, and the elevated process is authorized like
|
||||||
|
// any other privileged caller, from the identity the kernel reports for it.
|
||||||
|
//
|
||||||
|
// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
|
||||||
|
// no prompt to raise, or the elevated run failed, the error carries the command
|
||||||
|
// that does the same thing from a terminal.
|
||||||
|
func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
|
||||||
|
settings := guardedSettings(p)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return SaveOutcome{}, &ClientError{
|
||||||
|
Code: CodeElevationFailed,
|
||||||
|
Short: "no setting to apply",
|
||||||
|
Long: "no setting to apply",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The elevated run has no window and, on Linux, an environment pkexec has
|
||||||
|
// cleared, so what it writes to stderr is all there is to go on. It follows
|
||||||
|
// this process's level so that starting the app with --log-level debug says
|
||||||
|
// something about the run behind the prompt too.
|
||||||
|
args := append([]string{
|
||||||
|
"--" + FlagApplyPrivilegedSettings,
|
||||||
|
"--" + FlagDaemonAddr, s.daemonAddr,
|
||||||
|
"--" + FlagProfile, p.ProfileName,
|
||||||
|
"--" + FlagUser, p.Username,
|
||||||
|
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||||
|
}, oneShotArgs(settings)...)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// These changes hand out shells on this host, so both ends are logged: when the
|
||||||
|
// prompt went up, and what came of it. It is also the only account of a prompt
|
||||||
|
// that was slow to appear or never answered.
|
||||||
|
log.Infof("asking for privileges to apply %s", guardedSummary(p))
|
||||||
|
|
||||||
|
if err := s.elevator.Run(ctx, args...); err != nil {
|
||||||
|
return s.elevationOutcome(err, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
|
||||||
|
return SaveOutcome{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// elevationOutcome sorts what came back into the one normal ending and the two
|
||||||
|
// that need reporting, with the command that does the same thing by hand.
|
||||||
|
func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, elevate.ErrDeclined):
|
||||||
|
// With the reason: an account that may not elevate at all lands here too,
|
||||||
|
// and the log is the only place that says which it was.
|
||||||
|
log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
|
||||||
|
return SaveOutcome{Declined: true}, nil
|
||||||
|
case errors.Is(err, elevate.ErrUnavailable):
|
||||||
|
log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
|
||||||
|
return SaveOutcome{}, &ClientError{
|
||||||
|
Code: CodeElevationUnavailable,
|
||||||
|
Short: s.classifier.translateShort(CodeElevationUnavailable),
|
||||||
|
Long: err.Error(),
|
||||||
|
Command: guardedCommand(p),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
|
||||||
|
return SaveOutcome{}, &ClientError{
|
||||||
|
Code: CodeElevationFailed,
|
||||||
|
Short: s.classifier.translateShort(CodeElevationFailed),
|
||||||
|
Long: err.Error(),
|
||||||
|
Command: guardedCommand(p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedSettings renders the settings that are actually being changed, from the
|
||||||
|
// same table the one-shot parses them with: see oneshot.go.
|
||||||
|
func guardedSettings(p GuardedSettings) []guardedSetting {
|
||||||
|
var settings []guardedSetting
|
||||||
|
for _, field := range guardedFields {
|
||||||
|
value, ok := field.read(p)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings = append(settings, guardedSetting{
|
||||||
|
arg: "--" + field.flag + "=" + value,
|
||||||
|
flag: field.up(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneShotArgs(settings []guardedSetting) []string {
|
||||||
|
args := make([]string, 0, len(settings))
|
||||||
|
for _, setting := range settings {
|
||||||
|
args = append(args, setting.arg)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func upFlags(settings []guardedSetting) []string {
|
||||||
|
flags := make([]string, 0, len(settings))
|
||||||
|
for _, setting := range settings {
|
||||||
|
flags = append(flags, setting.flag)
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedCommand is the elevated command line equivalent to the requested
|
||||||
|
// change, the same shape the daemon names in its own refusals.
|
||||||
|
func guardedCommand(p GuardedSettings) string {
|
||||||
|
settings := guardedSettings(p)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedSummary names the change for the log.
|
||||||
|
func guardedSummary(p GuardedSettings) string {
|
||||||
|
return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
|
||||||
|
}
|
||||||
355
client/ui/services/guarded_test.go
Normal file
355
client/ui/services/guarded_test.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Unix socket, so the daemon address is one that carries a caller's identity and
|
||||||
|
// elevation is worth offering at all: see Settings.canElevate.
|
||||||
|
const testDaemonAddr = "unix:///var/run/netbird.sock"
|
||||||
|
|
||||||
|
// storedManagementURL is what the stub daemon already holds, so that a request
|
||||||
|
// naming a different one is a change: see Settings.guardedChanges.
|
||||||
|
const storedManagementURL = "https://stored.example.com"
|
||||||
|
|
||||||
|
// stubElevator stands in for the platform's prompt: it records what would have run
|
||||||
|
// and answers with a fixed outcome.
|
||||||
|
type stubElevator struct {
|
||||||
|
outcome error
|
||||||
|
available bool
|
||||||
|
calls [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *stubElevator) Run(_ context.Context, args ...string) error {
|
||||||
|
e.calls = append(e.calls, args)
|
||||||
|
return e.outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *stubElevator) Available() bool { return e.available }
|
||||||
|
|
||||||
|
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
|
||||||
|
// any other call panics rather than passing quietly.
|
||||||
|
type stubDaemon struct {
|
||||||
|
proto.DaemonServiceClient
|
||||||
|
setConfig func(*proto.SetConfigRequest) error
|
||||||
|
// stored is what GetConfig reports, which is what a refused request's guarded
|
||||||
|
// settings are compared against.
|
||||||
|
stored *proto.GetConfigResponse
|
||||||
|
requests []*proto.SetConfigRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
|
||||||
|
d.requests = append(d.requests, in)
|
||||||
|
if err := d.setConfig(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &proto.SetConfigResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
|
||||||
|
return d.stored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubConn struct{ client proto.DaemonServiceClient }
|
||||||
|
|
||||||
|
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
|
||||||
|
|
||||||
|
// privilegeRefusal is the error the daemon raises for a change it restricts to
|
||||||
|
// root, detail and all: see server.privilegeError.
|
||||||
|
func privilegeRefusal(t *testing.T) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
|
||||||
|
WithDetails(&errdetails.ErrorInfo{
|
||||||
|
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
||||||
|
Domain: ipcauth.ErrorDomain,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
|
||||||
|
ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "build the refusal detail")
|
||||||
|
return st.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
elev := &stubElevator{outcome: outcome, available: true}
|
||||||
|
return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
|
||||||
|
// for want of privileges and accepts anything after it. Its stored config holds
|
||||||
|
// another management server and no SSH grants, so a request naming either is a
|
||||||
|
// change rather than a restatement.
|
||||||
|
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
refusal := privilegeRefusal(t)
|
||||||
|
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
|
||||||
|
daemon.setConfig = func(*proto.SetConfigRequest) error {
|
||||||
|
if len(daemon.requests) == 1 {
|
||||||
|
return refusal
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
|
||||||
|
s, elev := settingsWithElevation(t, nil)
|
||||||
|
|
||||||
|
root := true
|
||||||
|
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||||
|
ProfileName: "work",
|
||||||
|
Username: "vma",
|
||||||
|
EnableSSHRoot: &root,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"--" + FlagApplyPrivilegedSettings,
|
||||||
|
"--" + FlagDaemonAddr, testDaemonAddr,
|
||||||
|
"--" + FlagProfile, "work",
|
||||||
|
"--" + FlagUser, "vma",
|
||||||
|
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||||
|
"--" + FlagEnableSSHRoot + "=true",
|
||||||
|
}
|
||||||
|
require.Len(t, elev.calls, 1, "one prompt for one change")
|
||||||
|
assert.Equal(t, want, elev.calls[0], "elevated arguments")
|
||||||
|
|
||||||
|
// argv[1] is what the polkit action is pinned to, so the marker has to stay
|
||||||
|
// first however the rest of the line grows.
|
||||||
|
assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning a setting off has to be as explicit as turning it on: a bare flag would
|
||||||
|
// read as "on" to the one-shot's parser.
|
||||||
|
func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
|
||||||
|
s, elev := settingsWithElevation(t, nil)
|
||||||
|
|
||||||
|
off := false
|
||||||
|
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||||
|
ProfileName: "default",
|
||||||
|
ServerSSHAllowed: &off,
|
||||||
|
DisableSSHAuth: &off,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
args := elev.calls[0]
|
||||||
|
assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
|
||||||
|
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
|
||||||
|
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
|
||||||
|
s, elev := settingsWithElevation(t, nil)
|
||||||
|
|
||||||
|
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: "https://mgmt.example.com:33073",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
|
||||||
|
"the management URL to point the profile at")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
|
||||||
|
s, elev := settingsWithElevation(t, nil)
|
||||||
|
|
||||||
|
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
|
||||||
|
|
||||||
|
require.Error(t, err, "nothing to apply is not something to prompt for")
|
||||||
|
assert.Empty(t, elev.calls, "no prompt at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A declined prompt is the one ending that is not an error: reporting it as one
|
||||||
|
// would have every cancelled prompt logged as a failure.
|
||||||
|
func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
|
||||||
|
s, _ := settingsWithElevation(t, elevate.ErrDeclined)
|
||||||
|
|
||||||
|
root := true
|
||||||
|
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||||
|
ProfileName: "default",
|
||||||
|
EnableSSHRoot: &root,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err, "the user was asked and answered; nothing went wrong")
|
||||||
|
assert.True(t, outcome.Declined, "nothing was applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGuardedSettingsMapsFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outcome error
|
||||||
|
wantCode string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Nothing to raise a prompt with: the user needs the command.
|
||||||
|
name: "no mechanism falls back to the command",
|
||||||
|
outcome: elevate.ErrUnavailable,
|
||||||
|
wantCode: CodeElevationUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a failed run falls back to the command",
|
||||||
|
outcome: errors.New("elevated netbird exited with 1"),
|
||||||
|
wantCode: CodeElevationFailed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s, _ := settingsWithElevation(t, tt.outcome)
|
||||||
|
|
||||||
|
root := true
|
||||||
|
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||||
|
ProfileName: "default",
|
||||||
|
EnableSSHRoot: &root,
|
||||||
|
})
|
||||||
|
|
||||||
|
var clientErr *ClientError
|
||||||
|
require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
|
||||||
|
assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
|
||||||
|
assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
|
||||||
|
"the setting in the fallback command")
|
||||||
|
assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing the management URL is only privileged while the host runs the SSH
|
||||||
|
// server, which no control can know up front, so the refusal is what triggers the
|
||||||
|
// prompt. The original request goes again afterwards, so the fields the one-shot
|
||||||
|
// does not understand are applied too.
|
||||||
|
func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
|
||||||
|
elev := &stubElevator{available: true}
|
||||||
|
s, daemon := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
mtu := int64(1280)
|
||||||
|
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: "https://mgmt.example.com",
|
||||||
|
MTU: &mtu,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||||
|
|
||||||
|
require.Len(t, elev.calls, 1, "one prompt")
|
||||||
|
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
|
||||||
|
"the guarded part of the request")
|
||||||
|
require.Len(t, daemon.requests, 2, "the refused request and the retry")
|
||||||
|
assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
|
||||||
|
"the retry carries the rest of the request, which the one-shot does not understand")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
|
||||||
|
elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
|
||||||
|
s, daemon := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: "https://mgmt.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err, "a declined prompt is not an error")
|
||||||
|
assert.True(t, outcome.Declined, "nothing was applied")
|
||||||
|
assert.Len(t, daemon.requests, 1, "only the refused request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
|
||||||
|
// the guidance that was there before elevation existed.
|
||||||
|
func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
|
||||||
|
elev := &stubElevator{available: false}
|
||||||
|
s, _ := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: "https://mgmt.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
var clientErr *ClientError
|
||||||
|
require.ErrorAs(t, err, &clientErr)
|
||||||
|
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||||
|
assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
|
||||||
|
"the daemon's own command")
|
||||||
|
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One authorization must buy only the change the user made. A settings form
|
||||||
|
// submits every field it holds, so most of a refused request restates what the
|
||||||
|
// daemon already has, and elevating those too would apply a guarded setting the
|
||||||
|
// user never touched — a value gone stale since the form loaded above all.
|
||||||
|
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
|
||||||
|
elev := &stubElevator{available: true}
|
||||||
|
s, _ := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
on, off := true, false
|
||||||
|
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: storedManagementURL,
|
||||||
|
ServerSSHAllowed: &off,
|
||||||
|
EnableSSHRoot: &off,
|
||||||
|
DisableSSHAuth: &on,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, elev.calls, 1, "one prompt")
|
||||||
|
args := elev.calls[0]
|
||||||
|
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
|
||||||
|
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
|
||||||
|
"a management URL the daemon already holds")
|
||||||
|
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
|
||||||
|
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request that changes no guarded setting has nothing an elevated run could
|
||||||
|
// apply, so the refusal must have come from somewhere a prompt cannot reach.
|
||||||
|
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
|
||||||
|
elev := &stubElevator{available: true}
|
||||||
|
s, _ := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
off := false
|
||||||
|
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||||
|
ProfileName: "default",
|
||||||
|
ManagementURL: storedManagementURL,
|
||||||
|
ServerSSHAllowed: &off,
|
||||||
|
})
|
||||||
|
|
||||||
|
var clientErr *ClientError
|
||||||
|
require.ErrorAs(t, err, &clientErr)
|
||||||
|
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||||
|
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal with nothing in the request the one-shot could apply: the daemon
|
||||||
|
// cannot see who is calling, and being root would not help either.
|
||||||
|
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
|
||||||
|
elev := &stubElevator{available: true}
|
||||||
|
s, _ := settingsRefusingOnce(t, elev)
|
||||||
|
|
||||||
|
_, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
|
||||||
|
|
||||||
|
var clientErr *ClientError
|
||||||
|
require.ErrorAs(t, err, &clientErr)
|
||||||
|
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||||
|
assert.Empty(t, elev.calls, "no prompt")
|
||||||
|
}
|
||||||
239
client/ui/services/oneshot.go
Normal file
239
client/ui/services/oneshot.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The other end of SetGuardedSettings: the mode this binary runs itself in,
|
||||||
|
// elevated, to apply the settings the daemon restricts to root/administrator.
|
||||||
|
//
|
||||||
|
// Both ends are here on purpose. What may be changed this way is an allowlist, and
|
||||||
|
// an allowlist declared twice is one that will eventually disagree with itself, so
|
||||||
|
// the arguments are rendered and parsed from a single table: guardedFields. Adding
|
||||||
|
// a setting is one row; nothing generic passes through, and no field outside the
|
||||||
|
// table can be reached with an elevated request no matter what lands on the command
|
||||||
|
// line.
|
||||||
|
|
||||||
|
// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
|
||||||
|
// because the user has just waited for an authentication dialog, and a failure here
|
||||||
|
// costs them the entire round trip.
|
||||||
|
const oneShotTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Exit codes the parent reads where the platform gives it one.
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitFailure = 1
|
||||||
|
exitUsage = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// guardedField is one setting the one-shot understands, in the two spellings it
|
||||||
|
// needs and with the two halves of its plumbing.
|
||||||
|
type guardedField struct {
|
||||||
|
// flag names it on the one-shot's command line.
|
||||||
|
flag string
|
||||||
|
usage string
|
||||||
|
// read returns the value to send and whether the caller asked for this setting
|
||||||
|
// at all.
|
||||||
|
read func(GuardedSettings) (string, bool)
|
||||||
|
// write parses a value from the command line onto the request. It is the only
|
||||||
|
// thing that validates the value, so it fails on anything it does not
|
||||||
|
// recognise rather than guessing.
|
||||||
|
write func(*proto.SetConfigRequest, string) error
|
||||||
|
// up renders the equivalent `netbird up` flag, for the fallback command shown
|
||||||
|
// when there is no prompt to raise.
|
||||||
|
up func(value string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var guardedFields = []guardedField{
|
||||||
|
{
|
||||||
|
flag: FlagManagementURL,
|
||||||
|
usage: "Management server the profile registers with.",
|
||||||
|
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
|
||||||
|
write: func(req *proto.SetConfigRequest, value string) error {
|
||||||
|
// Parsed with the config layer's own parser, so what the elevated run
|
||||||
|
// accepts cannot drift from what the daemon would store.
|
||||||
|
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.ManagementUrl = value
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// The daemon names this one as `-m <url>` in its own refusals.
|
||||||
|
up: func(value string) string { return "-m " + value },
|
||||||
|
},
|
||||||
|
boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
|
||||||
|
func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
|
||||||
|
func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
|
||||||
|
boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
|
||||||
|
func(p GuardedSettings) *bool { return p.EnableSSHRoot },
|
||||||
|
func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
|
||||||
|
boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
|
||||||
|
func(p GuardedSettings) *bool { return p.DisableSSHAuth },
|
||||||
|
func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldValue is a flag that remembers whether it was given, and requires a value:
|
||||||
|
// the renderer always writes one, so a bare flag is a caller that got it wrong.
|
||||||
|
type fieldValue struct {
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fieldValue) String() string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fieldValue) Set(value string) error {
|
||||||
|
v.set, v.value = true, value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolField describes a setting that is on or off. The value is always spelled out,
|
||||||
|
// so that turning a setting off is as unambiguous as turning it on and a flag with
|
||||||
|
// no value is a mistake rather than an "on".
|
||||||
|
func boolField(
|
||||||
|
name, usage string,
|
||||||
|
read func(GuardedSettings) *bool,
|
||||||
|
write func(*proto.SetConfigRequest, *bool),
|
||||||
|
) guardedField {
|
||||||
|
return guardedField{
|
||||||
|
flag: name,
|
||||||
|
usage: usage,
|
||||||
|
read: func(p GuardedSettings) (string, bool) {
|
||||||
|
value := read(p)
|
||||||
|
if value == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strconv.FormatBool(*value), true
|
||||||
|
},
|
||||||
|
write: func(req *proto.SetConfigRequest, value string) error {
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse %q as a boolean: %w", value, err)
|
||||||
|
}
|
||||||
|
write(req, &parsed)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
up: func(value string) string { return "--" + name + "=" + value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
|
||||||
|
// The flag is a marker rather than a value, so only the bare forms count: reading a
|
||||||
|
// value would mean "--flag=false" started it too.
|
||||||
|
func IsPrivilegedSettingsRun(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPrivilegedSettings applies the requested settings and returns the process exit
|
||||||
|
// code. connect dials the daemon, which is the caller's business because only it
|
||||||
|
// knows how this build talks to it.
|
||||||
|
//
|
||||||
|
// Everything it reports goes to stderr, which is what the parent captures where the
|
||||||
|
// platform lets it. On success it says so on standard output, because macOS gives
|
||||||
|
// the parent no exit status to read: see elevate.AppliedMarker.
|
||||||
|
func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
|
||||||
|
fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
|
||||||
|
fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
|
||||||
|
daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
|
||||||
|
logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
|
||||||
|
profile := fs.String(FlagProfile, "", "Profile to change.")
|
||||||
|
username := fs.String(FlagUser, "", "Owner of the profile.")
|
||||||
|
|
||||||
|
values := make([]fieldValue, len(guardedFields))
|
||||||
|
for i, field := range guardedFields {
|
||||||
|
fs.Var(&values[i], field.flag, field.usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.InitLog(*logLevel, "console"); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "init log: %v\n", err)
|
||||||
|
return exitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := privilegedRequest(*profile, *username, values)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
|
||||||
|
return exitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// privilegedRequest builds the request from the flags that were given, and refuses
|
||||||
|
// one that asks for nothing.
|
||||||
|
func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
|
||||||
|
req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
|
||||||
|
|
||||||
|
given := 0
|
||||||
|
for i, field := range guardedFields {
|
||||||
|
if !values[i].set {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := field.write(req, values[i].value); err != nil {
|
||||||
|
return nil, fmt.Errorf("--%s: %w", field.flag, err)
|
||||||
|
}
|
||||||
|
given++
|
||||||
|
}
|
||||||
|
if given == 0 {
|
||||||
|
return nil, errors.New("no setting to apply")
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPrivilegedSettings(
|
||||||
|
ctx context.Context,
|
||||||
|
daemonAddr string,
|
||||||
|
req *proto.SetConfigRequest,
|
||||||
|
connect func(addr string) (proto.DaemonServiceClient, error),
|
||||||
|
) error {
|
||||||
|
client, err := connect(daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||||
|
// Unwrapped: the daemon's message is written for a person, and a refusal
|
||||||
|
// elevation cannot fix has to say so where the parent can read it off
|
||||||
|
// stderr.
|
||||||
|
return errors.New(gstatus.Convert(err).Message())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface guard: the one-shot's flags are flag.Value.
|
||||||
|
var _ flag.Value = (*fieldValue)(nil)
|
||||||
151
client/ui/services/oneshot_test.go
Normal file
151
client/ui/services/oneshot_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsPrivilegedSettingsRun(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "no arguments"},
|
||||||
|
{name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
|
||||||
|
{name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
|
||||||
|
{
|
||||||
|
name: "among other flags",
|
||||||
|
args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
// A marker, not a value: the caller never passes one, and reading a value
|
||||||
|
// would mean "--flag=false" started the one-shot too.
|
||||||
|
{name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
|
||||||
|
{name: "unrelated flags", args: []string{"--log-level", "debug"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What SetGuardedSettings renders has to be what the one-shot reads back, for every
|
||||||
|
// setting in the table. This is the property that keeps the two ends of an allowlist
|
||||||
|
// from drifting, so it is checked field by field rather than by example.
|
||||||
|
func TestGuardedFieldsRoundTrip(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
settings GuardedSettings
|
||||||
|
want func(*testing.T, *proto.SetConfigRequest)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "management url",
|
||||||
|
settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
|
||||||
|
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||||
|
assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh server on",
|
||||||
|
settings: GuardedSettings{ServerSSHAllowed: &on},
|
||||||
|
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||||
|
require.NotNil(t, req.ServerSSHAllowed)
|
||||||
|
assert.True(t, *req.ServerSSHAllowed)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh root off",
|
||||||
|
settings: GuardedSettings{EnableSSHRoot: &off},
|
||||||
|
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||||
|
require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
|
||||||
|
assert.False(t, *req.EnableSSHRoot)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh auth off",
|
||||||
|
settings: GuardedSettings{DisableSSHAuth: &on},
|
||||||
|
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||||
|
require.NotNil(t, req.DisableSSHAuth)
|
||||||
|
assert.True(t, *req.DisableSSHAuth)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := parseRendered(t, tt.settings)
|
||||||
|
tt.want(t, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A setting nobody asked about must not arrive at the daemon at all: sending its
|
||||||
|
// zero value would change it.
|
||||||
|
func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
|
||||||
|
on := true
|
||||||
|
req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
|
||||||
|
|
||||||
|
assert.Equal(t, "work", req.GetProfileName(), "profile")
|
||||||
|
require.NotNil(t, req.EnableSSHRoot)
|
||||||
|
assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
|
||||||
|
assert.Nil(t, req.DisableSSHAuth, "untouched setting")
|
||||||
|
assert.Empty(t, req.GetManagementUrl(), "untouched setting")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
|
||||||
|
_, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
|
||||||
|
require.Error(t, err, "nothing to apply is not a request worth sending as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value the table cannot parse is refused rather than guessed at.
|
||||||
|
func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
|
||||||
|
values := make([]fieldValue, len(guardedFields))
|
||||||
|
for i, field := range guardedFields {
|
||||||
|
if field.flag != FlagEnableSSHRoot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.NoError(t, values[i].Set("perhaps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := privilegedRequest("default", "vma", values)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRendered puts the settings through both ends: rendered as the arguments the
|
||||||
|
// elevated process is given, then parsed by a flag set registered from the same
|
||||||
|
// table, which is what the one-shot itself parses them with. Anything hand-rolled
|
||||||
|
// here would pin down a parser nothing uses.
|
||||||
|
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rendered := guardedSettings(p)
|
||||||
|
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
|
||||||
|
|
||||||
|
args := make([]string, 0, len(rendered))
|
||||||
|
for _, setting := range rendered {
|
||||||
|
args = append(args, setting.arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
|
||||||
|
values := make([]fieldValue, len(guardedFields))
|
||||||
|
for i, field := range guardedFields {
|
||||||
|
fs.Var(&values[i], field.flag, field.usage)
|
||||||
|
}
|
||||||
|
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
|
||||||
|
|
||||||
|
req, err := privilegedRequest(p.ProfileName, p.Username, values)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||||
ProfileName: p.ProfileName,
|
ProfileName: p.ProfileName,
|
||||||
Username: p.Username,
|
Username: p.Username,
|
||||||
})
|
})
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ type Restrictions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Privilege tells the frontend whether this process may perform the changes the
|
// 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
|
// daemon restricts to root/administrator, whether it can ask the operating
|
||||||
// disabled control can show the way to do it.
|
// system for the privileges instead, and the command for each so a control that
|
||||||
|
// can do neither can still show the way.
|
||||||
type Privilege struct {
|
type Privilege struct {
|
||||||
Privileged bool `json:"privileged"`
|
Privileged bool `json:"privileged"`
|
||||||
// Actor names what the operation requires ("root", "administrator privileges").
|
// ActorKey identifies the principal the operation requires without wording it,
|
||||||
Actor string `json:"actor"`
|
// so the frontend can name it in the user's language: see
|
||||||
|
// ipcauth.PrivilegedActorKey. The words are not sent, because English ones
|
||||||
|
// cannot be dropped into a translated sentence.
|
||||||
|
ActorKey string `json:"actorKey"`
|
||||||
|
// CanElevate reports whether a guarded control can offer to authorize the
|
||||||
|
// change through the platform's own prompt: see SetGuardedSettings.
|
||||||
|
CanElevate bool `json:"canElevate"`
|
||||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||||
AllowSSHServer string `json:"allowSshServer"`
|
AllowSSHServer string `json:"allowSshServer"`
|
||||||
EnableSSHRoot string `json:"enableSshRoot"`
|
EnableSSHRoot string `json:"enableSshRoot"`
|
||||||
@@ -128,6 +135,9 @@ type Settings struct {
|
|||||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||||
// this user and would therefore authorize us: see Privilege.
|
// this user and would therefore authorize us: see Privilege.
|
||||||
daemonAddr string
|
daemonAddr string
|
||||||
|
// elevator raises the platform's privilege prompt when a change needs more
|
||||||
|
// rights than this process has.
|
||||||
|
elevator elevator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||||
@@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||||
daemonAddr: daemonAddr,
|
daemonAddr: daemonAddr,
|
||||||
|
elevator: osElevator{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +191,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return SaveOutcome{}, err
|
||||||
}
|
}
|
||||||
req := &proto.SetConfigRequest{
|
req := &proto.SetConfigRequest{
|
||||||
ProfileName: p.ProfileName,
|
ProfileName: p.ProfileName,
|
||||||
@@ -215,19 +226,92 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
|||||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||||
}
|
}
|
||||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||||
|
if _, refused := privilegeErrorInfo(err); refused {
|
||||||
|
return s.setConfigElevated(ctx, p, req, err)
|
||||||
|
}
|
||||||
// Classified so the frontend gets the daemon's guidance instead of the
|
// Classified so the frontend gets the daemon's guidance instead of the
|
||||||
// gRPC envelope, which is what a refused privileged change looks like.
|
// gRPC envelope.
|
||||||
return s.classifier.classify(err)
|
return SaveOutcome{}, s.classifier.classify(err)
|
||||||
}
|
}
|
||||||
return nil
|
return SaveOutcome{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setConfigElevated answers a request the daemon refused for want of privileges by
|
||||||
|
// asking the user to authorize it, and sending it again if they do. It is the same
|
||||||
|
// offer the SSH settings make up front, for the changes a control cannot know are
|
||||||
|
// guarded until it is told: repointing a profile at another management server is
|
||||||
|
// only privileged while that host runs the SSH server.
|
||||||
|
//
|
||||||
|
// Two steps, because the elevated one-shot deliberately understands only the
|
||||||
|
// settings the daemon guards: it applies those, and the original request then goes
|
||||||
|
// through as this user, its privileged parts now asking for nothing that is not
|
||||||
|
// already stored. Nothing was applied by the refused attempt — the daemon decides
|
||||||
|
// before it writes — so there is no half-applied state to undo either way.
|
||||||
|
func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
|
||||||
|
if !s.canElevate() {
|
||||||
|
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||||
|
}
|
||||||
|
|
||||||
|
guarded, err := s.guardedChanges(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
|
||||||
|
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||||
|
}
|
||||||
|
if len(guardedSettings(guarded)) == 0 {
|
||||||
|
// Refused over something no prompt can settle, such as a control channel
|
||||||
|
// that carries no caller identity. Report the daemon's own guidance.
|
||||||
|
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome, err := s.SetGuardedSettings(ctx, guarded)
|
||||||
|
if err != nil || outcome.Declined {
|
||||||
|
return outcome, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := s.conn.Client()
|
||||||
|
if err != nil {
|
||||||
|
return SaveOutcome{}, err
|
||||||
|
}
|
||||||
|
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||||
|
return SaveOutcome{}, s.classifier.classify(err)
|
||||||
|
}
|
||||||
|
return SaveOutcome{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedChanges is the guarded part of a request, reduced to what it actually
|
||||||
|
// changes.
|
||||||
|
//
|
||||||
|
// A settings form submits every field it holds, so a request restates values the
|
||||||
|
// daemon already has. Carrying those into the elevated run would spend one
|
||||||
|
// authorization on more than the user asked for, and a value that has gone stale
|
||||||
|
// since the form was loaded would spend it on something they never asked about.
|
||||||
|
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
|
||||||
|
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
|
||||||
|
if err != nil {
|
||||||
|
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
guarded := GuardedSettings{
|
||||||
|
ProfileName: p.ProfileName,
|
||||||
|
Username: p.Username,
|
||||||
|
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
|
||||||
|
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
|
||||||
|
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
|
||||||
|
}
|
||||||
|
// An empty URL leaves the setting alone, which is the daemon's rule too.
|
||||||
|
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
|
||||||
|
guarded.ManagementURL = p.ManagementURL
|
||||||
|
}
|
||||||
|
return guarded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privilege reports whether this UI process could carry out the changes the
|
// Privilege reports whether this UI process could carry out the changes the
|
||||||
// daemon restricts to root/administrator, and the command that performs the one
|
// daemon restricts to root/administrator, whether it can instead ask the
|
||||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
// operating system for the privileges when the user wants one of them, and the
|
||||||
// see locally, so the frontend can present those controls as unavailable up front
|
// command that performs the ones users hit in the SSH settings. It applies the
|
||||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
// daemon's own rule to what it can see locally, so the frontend can decide up
|
||||||
// daemon is down.
|
// front how to present those controls 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
|
// 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
|
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||||
@@ -237,26 +321,40 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
|||||||
func (s *Settings) Privilege() Privilege {
|
func (s *Settings) Privilege() Privilege {
|
||||||
id, err := ipcauth.CurrentProcessIdentity()
|
id, err := ipcauth.CurrentProcessIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fail closed: report unprivileged, which only ever disables controls.
|
// Fail closed: report unprivileged, which only ever asks for more.
|
||||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||||
return newPrivilege(false)
|
return s.newPrivilege(false)
|
||||||
}
|
}
|
||||||
if id.IsPrivileged() {
|
if id.IsPrivileged() {
|
||||||
return newPrivilege(true)
|
return s.newPrivilege(true)
|
||||||
}
|
}
|
||||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPrivilege(privileged bool) Privilege {
|
func (s *Settings) newPrivilege(privileged bool) Privilege {
|
||||||
return Privilege{
|
return Privilege{
|
||||||
Privileged: privileged,
|
Privileged: privileged,
|
||||||
Actor: ipcauth.PrivilegedActor(),
|
ActorKey: ipcauth.PrivilegedActorKey(),
|
||||||
|
CanElevate: s.canElevate(),
|
||||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canElevate reports whether offering the platform's elevation prompt would get
|
||||||
|
// the user anywhere. It needs a mechanism to raise the prompt with and a control
|
||||||
|
// channel that tells the daemon who is calling: on loopback TCP the daemon
|
||||||
|
// refuses these changes to everybody, root included, so a prompt there would
|
||||||
|
// only waste the user's password.
|
||||||
|
func (s *Settings) canElevate() bool {
|
||||||
|
if !daemonaddr.CarriesIdentity(s.daemonAddr) {
|
||||||
|
log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.elevator.Available()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changedFlag returns requested only when it differs from what is stored, so a
|
||||||
|
// setting the request merely restates is left out of the elevated run.
|
||||||
|
func changedFlag(requested *bool, stored bool) *bool {
|
||||||
|
if requested == nil || *requested == stored {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||||
managed := cfgResp.GetMDMManagedFields()
|
managed := cfgResp.GetMDMManagedFields()
|
||||||
if len(managed) == 0 {
|
if len(managed) == 0 {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type TrayServices struct {
|
|||||||
Profiles *services.Profiles
|
Profiles *services.Profiles
|
||||||
Networks *services.Networks
|
Networks *services.Networks
|
||||||
DaemonFeed *services.DaemonFeed
|
DaemonFeed *services.DaemonFeed
|
||||||
Notifier *notifications.NotificationService
|
Notifier *Notifier
|
||||||
Update *services.Update
|
Update *services.Update
|
||||||
ProfileSwitcher *services.ProfileSwitcher
|
ProfileSwitcher *services.ProfileSwitcher
|
||||||
WindowManager *services.WindowManager
|
WindowManager *services.WindowManager
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ package main
|
|||||||
|
|
||||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||||
//
|
//
|
||||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
// Expected behaviour per tray host:
|
||||||
// fires the registered clickHandler:
|
//
|
||||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
// Host Left click Right click
|
||||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
// GNOME Shell + AppIndicator menu only menu only
|
||||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
//
|
||||||
// Registering OnClick here therefore covers both paths with one handler — no
|
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||||
// main window; right-click still opens the menu via Wails' default
|
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
//
|
||||||
|
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
|
||||||
|
// on ANY click and only reports the menu opening via dbusmenu
|
||||||
|
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
|
||||||
|
// both buttons raised the main window on top of the menu, and on KDE/Waybar
|
||||||
|
// a right click raised it over the freshly opened menu. The netbirdio/wails
|
||||||
|
// fork (go.mod replace) drops that heuristic: a menu open never fires
|
||||||
|
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
|
||||||
|
// entry; left-click-opens-window is not achievable there anyway, since the
|
||||||
|
// host always opens the menu itself.
|
||||||
//
|
//
|
||||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
|
|||||||
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
||||||
// is reachable but too old for this UI. A probe error means the daemon isn't
|
// is reachable but too old for this UI. A probe error means the daemon isn't
|
||||||
// reachable (not outdated), so it is left to the normal connection flow.
|
// reachable (not outdated), so it is left to the normal connection flow.
|
||||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
|
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
|
||||||
ready, err := compat.DaemonReady(context.Background())
|
ready, err := compat.DaemonReady(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("daemon compatibility probe: %v", err)
|
log.Debugf("daemon compatibility probe: %v", err)
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ const (
|
|||||||
finalWarningCountdownSeconds = 120
|
finalWarningCountdownSeconds = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
|
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
||||||
func (t *Tray) handleSessionExpired() {
|
func (t *Tray) handleSessionExpired() {
|
||||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||||
if t.window != nil {
|
if t.window != nil {
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
t.window.Show()
|
||||||
t.window.Focus()
|
t.window.Focus()
|
||||||
}
|
}
|
||||||
@@ -308,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
|
|||||||
}
|
}
|
||||||
seconds := int(time.Until(deadline).Seconds())
|
seconds := int(time.Until(deadline).Seconds())
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
if t.window != nil {
|
t.app.Event.Emit(services.EventTriggerLogin)
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
|
||||||
t.window.Focus()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t.svc.WindowManager == nil {
|
if t.svc.WindowManager == nil {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type trayUpdater struct {
|
|||||||
app *application.App
|
app *application.App
|
||||||
window *application.WebviewWindow
|
window *application.WebviewWindow
|
||||||
update *services.Update
|
update *services.Update
|
||||||
notifier *notifications.NotificationService
|
notifier *Notifier
|
||||||
loc *Localizer
|
loc *Localizer
|
||||||
onIconChange func()
|
onIconChange func()
|
||||||
// onMenuChange drives a full tray relayout: the update row lives in the
|
// onMenuChange drives a full tray relayout: the update row lives in the
|
||||||
@@ -36,7 +36,7 @@ type trayUpdater struct {
|
|||||||
progressWindowOpen bool
|
progressWindowOpen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||||
u := &trayUpdater{
|
u := &trayUpdater{
|
||||||
app: app,
|
app: app,
|
||||||
window: window,
|
window: window,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ sequenceDiagram
|
|||||||
Resp->>Resp: parse usage tokens, completion
|
Resp->>Resp: parse usage tokens, completion
|
||||||
Note over Resp: capture_completion gates raw<br/>completion capture
|
Note over Resp: capture_completion gates raw<br/>completion capture
|
||||||
Resp->>Cost: tokens
|
Resp->>Cost: tokens
|
||||||
Cost->>Cost: lookup rates from config-delivered<br/>pricing table + compute cost
|
Cost->>Cost: lookup pricing.yaml + compute cost
|
||||||
Cost->>Rec: tokens + cost
|
Cost->>Rec: tokens + cost
|
||||||
Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user)
|
Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user)
|
||||||
Rec-->>Log: emit access-log entry<br/>(if EnableLogCollection)
|
Rec-->>Log: emit access-log entry<br/>(if EnableLogCollection)
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthe
|
|||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger |
|
| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger |
|
||||||
| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain |
|
| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain |
|
||||||
| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config |
|
|
||||||
| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` |
|
|
||||||
| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) |
|
|
||||||
| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) |
|
|
||||||
| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) |
|
| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) |
|
||||||
| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete |
|
| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete |
|
||||||
| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) |
|
| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) |
|
||||||
@@ -52,8 +48,6 @@ flowchart TD
|
|||||||
I --> J[indexProviderGroups: providerID -> sorted source groups]
|
I --> J[indexProviderGroups: providerID -> sorted source groups]
|
||||||
J --> K[buildRouterConfigJSON drops orphan providers]
|
J --> K[buildRouterConfigJSON drops orphan providers]
|
||||||
J --> L[buildIdentityInjectConfigJSON per catalog entry]
|
J --> L[buildIdentityInjectConfigJSON per catalog entry]
|
||||||
J --> K2[buildCostMeterConfigJSON: default table + per-provider prices]
|
|
||||||
K2 --> P
|
|
||||||
H --> M[mergeGuardrails: union allowlist, OR redact]
|
H --> M[mergeGuardrails: union allowlist, OR redact]
|
||||||
M --> N[applyAccountCollectionControls account toggle = SOLE capture control]
|
M --> N[applyAccountCollectionControls account toggle = SOLE capture control]
|
||||||
N --> O[marshalGuardrailConfig]
|
N --> O[marshalGuardrailConfig]
|
||||||
@@ -66,84 +60,6 @@ flowchart TD
|
|||||||
R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map]
|
R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map]
|
||||||
```
|
```
|
||||||
|
|
||||||
### LLM pricing (management is the sole authority)
|
|
||||||
|
|
||||||
**The proxy carries no price list.** Management synthesizes the entire pricing
|
|
||||||
table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches
|
|
||||||
the proxies as an ordinary mapping push — the chain rebuild installs a fresh
|
|
||||||
table and there is nothing to reload on the proxy side.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults]
|
|
||||||
B --> C{AgentNetwork.PricingDefaultsFile}
|
|
||||||
C -- absent --> D[compiled-in table serves]
|
|
||||||
C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base]
|
|
||||||
E --> F[mergedTable atomic.Pointer]
|
|
||||||
D --> G[DefaultTable]
|
|
||||||
F --> G
|
|
||||||
G --> H[buildCostMeterConfigJSON — pricing.defaults]
|
|
||||||
I[types.Provider.Models operator prices] --> J[normalizePricingModelID<br/>bedrock ARN/region/version, vertex @version]
|
|
||||||
J --> K[materializeEntry: default entry as base,<br/>operator input/output verbatim,<br/>cache pointers only when non-nil]
|
|
||||||
K --> L[pricing.providers keyed by provider record ID]
|
|
||||||
H --> M[cost_meter ConfigJSON]
|
|
||||||
L --> M
|
|
||||||
G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows]
|
|
||||||
O[StartReloader: mtime poll every ReloadInterval 1m] --> E
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`):
|
|
||||||
|
|
||||||
- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model
|
|
||||||
id → rates. The **full** default table ships to every account: it is small
|
|
||||||
(~10 KB) and it is what keeps gateway-style providers (which enumerate no
|
|
||||||
models, so they claim every model) priced.
|
|
||||||
- `pricing.providers` — provider **record** id → normalized model id → rates,
|
|
||||||
matched against the `llm.resolved_provider_id` the router stamps. Entries are
|
|
||||||
**fully materialized here**, at synth time: `materializeEntry` starts from the
|
|
||||||
default entry for that model so cache rates the operator didn't state are
|
|
||||||
inherited, overlays operator `input`/`output` verbatim (**including an explicit
|
|
||||||
0**, which prices a self-hosted or internal endpoint as free rather than
|
|
||||||
silently reverting to list price), and overlays cache-rate **pointers only when
|
|
||||||
non-nil** — `nil` means "inherit the default", an explicit `0` means "no
|
|
||||||
discount, bill this bucket at the input rate". The proxy therefore does two map
|
|
||||||
lookups and no merging.
|
|
||||||
|
|
||||||
Same orphan rule as the router: a provider no enabled policy authorises is
|
|
||||||
unreachable, so its prices aren't shipped. Model ids are normalized with the
|
|
||||||
**same** functions the request parser uses (`NormalizeBedrockModel` /
|
|
||||||
`NormalizeVertexModel`), which is what makes the per-record lookup key compare
|
|
||||||
equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve
|
|
||||||
first-occurrence-wins, matching the routing dedup order.
|
|
||||||
|
|
||||||
**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator
|
|
||||||
replace default rates without a rebuild. Schema is `surface → model → rates`
|
|
||||||
(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` /
|
|
||||||
`cache_read_per_1k` / `cache_creation_per_1k`). Semantics:
|
|
||||||
|
|
||||||
- A **relative** path resolves against `<Datadir>`, so a bare filename lands
|
|
||||||
alongside the store. Empty config probes `<Datadir>/defaults_llm_pricing.yaml`.
|
|
||||||
- An **explicitly configured** path is *required to load*: a typo or malformed
|
|
||||||
file fails startup, because the operator believes those rates are live. The
|
|
||||||
conventional probe is optional — an absent file just serves compiled-in
|
|
||||||
defaults, and the path stays watched in case it appears later.
|
|
||||||
- File entries **replace** the compiled-in entry for the same (surface, model)
|
|
||||||
**whole** — they are not field-merged, so an entry must repeat the cache rates
|
|
||||||
it wants to keep. Everything the file doesn't mention keeps built-in rates.
|
|
||||||
- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be
|
|
||||||
finite and non-negative — the same constraints the HTTP API enforces on
|
|
||||||
operator per-provider prices.
|
|
||||||
- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**:
|
|
||||||
a parse error keeps the previous table, a deleted file reverts to compiled-in
|
|
||||||
defaults. A mid-edit save can never take pricing down.
|
|
||||||
|
|
||||||
The live table feeds **both** consumers, which is what keeps them consistent: the
|
|
||||||
synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog`
|
|
||||||
via `applyDefaultPricing` (what the dashboard's model-row prices prefill with).
|
|
||||||
`defaults_llm_pricing.example.yaml` is generated from the compiled-in table
|
|
||||||
(`go generate ./management/internals/modules/agentnetwork/pricing`) and
|
|
||||||
golden-tested, so operators start from a file matching the built-in rates exactly.
|
|
||||||
|
|
||||||
### Budget rule resolution (min-wins, group+user bound)
|
### Budget rule resolution (min-wins, group+user bound)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -208,7 +124,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
|||||||
| on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** |
|
| on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** |
|
||||||
| on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – |
|
| on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – |
|
||||||
| on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – |
|
| on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – |
|
||||||
| on_response | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – |
|
| on_response | 6 | `cost_meter` | `{}` | – |
|
||||||
| on_response | 7 | `llm_response_parser` | `{"capture_completion": <bool>, "redact_pii"?: true}` | – |
|
| on_response | 7 | `llm_response_parser` | `{"capture_completion": <bool>, "redact_pii"?: true}` | – |
|
||||||
- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=<subdomain>.<cluster>`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`.
|
- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=<subdomain>.<cluster>`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`.
|
||||||
|
|
||||||
@@ -223,12 +139,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
|||||||
- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry.
|
- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry.
|
||||||
- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`).
|
- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`).
|
||||||
- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced.
|
- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced.
|
||||||
- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management".
|
|
||||||
- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers.
|
|
||||||
- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`.
|
|
||||||
- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults.
|
|
||||||
- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates.
|
|
||||||
- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`).
|
|
||||||
|
|
||||||
## Things to scrutinize
|
## Things to scrutinize
|
||||||
|
|
||||||
@@ -266,12 +176,10 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
|||||||
- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value.
|
- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value.
|
||||||
- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating.
|
- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating.
|
||||||
- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out.
|
- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out.
|
||||||
- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0.
|
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions.
|
- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions.
|
||||||
- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build.
|
|
||||||
- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant.
|
- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant.
|
||||||
- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect.
|
- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect.
|
||||||
|
|
||||||
@@ -280,7 +188,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
|||||||
- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume).
|
- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume).
|
||||||
- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`).
|
- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`).
|
||||||
- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden.
|
- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden.
|
||||||
- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`).
|
|
||||||
|
|
||||||
## Test coverage
|
## Test coverage
|
||||||
|
|
||||||
@@ -291,9 +198,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
|||||||
| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. |
|
| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. |
|
||||||
| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. |
|
| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. |
|
||||||
| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. |
|
| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. |
|
||||||
| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. |
|
|
||||||
| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. |
|
|
||||||
| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. |
|
|
||||||
| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. |
|
| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. |
|
||||||
| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. |
|
| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. |
|
||||||
| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. |
|
| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ LLM request. The two highest-blast-radius areas are the **capture-pointer
|
|||||||
semantics** and the **limit_check ⇒ limit_record** record-once invariant.
|
semantics** and the **limit_check ⇒ limit_record** record-once invariant.
|
||||||
|
|
||||||
Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK
|
Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK
|
||||||
adapters + pricing table and cost formula this chain delegates to.
|
adapters + pricing catalog this chain delegates to.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ rewrites.
|
|||||||
| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite |
|
| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite |
|
||||||
| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) |
|
| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) |
|
||||||
| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none |
|
| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none |
|
||||||
| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) |
|
| `cost_meter` | OnResponse | `llm.{provider,model}`, token buckets | `cost.usd_total` or `cost.skipped` | pricing lookup |
|
||||||
| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` |
|
| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` |
|
||||||
|
|
||||||
[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go)
|
[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go)
|
||||||
@@ -44,7 +44,7 @@ locks the ID set; adding or removing one is a conscious extension.
|
|||||||
|
|
||||||
| File | LOC | Notes |
|
| File | LOC | Notes |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) |
|
| `builtin.go` | 86 | Registry + `FactoryContext` (ctx, data dir, meter, logger, mgmt client) |
|
||||||
| `all_test.go` | 41 | Locks the 8-ID registry surface |
|
| `all_test.go` | 41 | Locks the 8-ID registry surface |
|
||||||
| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path |
|
| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path |
|
||||||
| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating |
|
| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating |
|
||||||
@@ -53,7 +53,7 @@ locks the ID set; adding or removing one is a conscious extension.
|
|||||||
| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders |
|
| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders |
|
||||||
| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction |
|
| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction |
|
||||||
| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit |
|
| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit |
|
||||||
| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config |
|
| `cost_meter/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` |
|
||||||
| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) |
|
| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) |
|
||||||
|
|
||||||
## Per-middleware
|
## Per-middleware
|
||||||
@@ -168,46 +168,12 @@ token schema.
|
|||||||
|
|
||||||
### cost_meter
|
### cost_meter
|
||||||
|
|
||||||
Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates,
|
Reads `llm.provider` + `llm.model` + token buckets, looks up per-1k rate via
|
||||||
and emits the full `cost.usd_*` breakdown (four per-bucket values plus the
|
`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped`
|
||||||
`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason
|
reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
|
||||||
(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
|
`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime
|
||||||
`unknown_model`).
|
context via `startReloader`. **Key invariant:** provider-shape switch lives
|
||||||
|
in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic.
|
||||||
**Management owns pricing.** The proxy carries no embedded price list: the whole
|
|
||||||
table arrives in this middleware's `ConfigJSON` as
|
|
||||||
`{pricing: {defaults, providers}}`, synthesized by management from the catalog
|
|
||||||
plus the operator's stored per-provider prices
|
|
||||||
([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
|
|
||||||
Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at
|
|
||||||
construction, so a non-finite or negative rate fails the chain build. A price
|
|
||||||
change is an ordinary mapping push — the chain rebuild yields a fresh instance
|
|
||||||
over a fresh immutable table, so there is no data dir, no pricing file, no
|
|
||||||
reload goroutine, and nothing to invalidate.
|
|
||||||
|
|
||||||
**Two-tier lookup**
|
|
||||||
([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)):
|
|
||||||
|
|
||||||
1. **Per-provider-record** — the operator's stored price for the route that
|
|
||||||
actually served the request, keyed by the `llm.resolved_provider_id` that
|
|
||||||
`llm_router` stamped on the allow path, then by normalized model id. Entries
|
|
||||||
arrive fully materialized (management folds default cache rates in at synth
|
|
||||||
time), so there is no merging here. Absent metadata — no router in the chain
|
|
||||||
— skips this tier.
|
|
||||||
2. **Surface defaults** — the catalog-derived table keyed by `llm.provider`
|
|
||||||
(`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style
|
|
||||||
providers, which enumerate no models and therefore get no per-record entry.
|
|
||||||
|
|
||||||
**Backward compatibility:** a config with no `pricing` block means management
|
|
||||||
predates config-delivered pricing. The factory logs one warning at build time
|
|
||||||
and the instance records `cost.skipped=unknown_model` ($0) for every request
|
|
||||||
rather than falling back to a stale built-in price list
|
|
||||||
([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
|
|
||||||
|
|
||||||
**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts`
|
|
||||||
(sibling doc) and is selected by the **surface**, not by which tier the entry
|
|
||||||
came from — `cost_meter` stays provider-agnostic, and a per-record override on
|
|
||||||
an Anthropic route still bills its cache buckets additively.
|
|
||||||
|
|
||||||
### llm_limit_record
|
### llm_limit_record
|
||||||
|
|
||||||
@@ -280,14 +246,12 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter`
|
|||||||
| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` |
|
| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` |
|
||||||
| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) |
|
| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) |
|
||||||
| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` |
|
| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` |
|
||||||
| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` |
|
| `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) |
|
||||||
| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` |
|
| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` |
|
||||||
|
|
||||||
All factories accept empty / null / `{}` / whitespace as zero-value config;
|
All factories accept empty / null / `{}` / whitespace as zero-value config;
|
||||||
only structurally invalid JSON is rejected so misconfig surfaces at chain
|
only structurally invalid JSON is rejected so misconfig surfaces at chain
|
||||||
build time. `cost_meter` adds a semantic check on top of that: a `pricing`
|
build time.
|
||||||
block carrying a negative or non-finite rate fails the build too, rather than
|
|
||||||
mispricing live traffic.
|
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -356,11 +320,10 @@ non-object `metadata` field
|
|||||||
— header path still attributes, but body-level tag-budget enforcement
|
— header path still attributes, but body-level tag-budget enforcement
|
||||||
doesn't run for that request.
|
doesn't run for that request.
|
||||||
|
|
||||||
**Concurrency.** `cost_meter`'s two pricing tables are built once from the
|
**Concurrency.** `cost_meter` shares a `pricing.Loader` via
|
||||||
middleware config and never mutated, so the lookup path needs no lock or atomic
|
`atomic.Pointer[Table]`; readers always see a consistent table. Every
|
||||||
swap — a price change replaces the whole instance. Every middleware is
|
middleware is a stateless value receiver. Integration test uses real bufconn
|
||||||
otherwise a stateless value receiver. Integration test uses real bufconn gRPC —
|
gRPC — race detector is the meaningful bar.
|
||||||
race detector is the meaningful bar.
|
|
||||||
|
|
||||||
**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost`
|
**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost`
|
||||||
is O(1); SSE accumulation is single-pass. No map allocation per call.
|
is O(1); SSE accumulation is single-pass. No map allocation per call.
|
||||||
@@ -386,13 +349,13 @@ counter accuracy.
|
|||||||
| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven |
|
| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven |
|
||||||
| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation |
|
| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation |
|
||||||
| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort |
|
| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort |
|
||||||
| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection |
|
| `cost_meter/middleware_test.go` | 17 | Each skip reason, provider-shape, pricing loader integration |
|
||||||
| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed |
|
| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed |
|
||||||
|
|
||||||
## Cross-references
|
## Cross-references
|
||||||
|
|
||||||
- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters
|
- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters
|
||||||
+ SSE framer + pricing table and cost formula.
|
+ SSE framer + pricing loader.
|
||||||
- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP
|
- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP
|
||||||
token minting, `/bedrock` prefix:
|
token minting, `/bedrock` prefix:
|
||||||
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pricing table's per-provider cost formula is the highest-leverage place a
|
|||||||
small bug would silently mis-bill operators.
|
small bug would silently mis-bill operators.
|
||||||
|
|
||||||
Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
||||||
— the 8 middlewares that consume this package's parsers + pricing table.
|
— the 8 middlewares that consume this package's parsers + pricing loader.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,9 +24,8 @@ proxy-framework dependencies:
|
|||||||
- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls.
|
- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls.
|
||||||
- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`).
|
- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`).
|
||||||
- `errors.go` — sentinels callers branch on with `errors.Is`.
|
- `errors.go` — sentinels callers branch on with `errors.Is`.
|
||||||
- `pricing/` — immutable pricing table + the per-surface cost formula. The
|
- `pricing/` — embedded-default + hot-reload override table with
|
||||||
rates themselves come from management inside `cost_meter`'s middleware
|
symlink-safe Unix loader (build-tagged stub elsewhere).
|
||||||
config; this package holds no price list and reads no files.
|
|
||||||
- `fixtures/` — captured request/response/stream bodies the tests replay.
|
- `fixtures/` — captured request/response/stream bodies the tests replay.
|
||||||
|
|
||||||
The package carries zero proxy-framework dependencies so the same parsers can
|
The package carries zero proxy-framework dependencies so the same parsers can
|
||||||
@@ -48,9 +47,12 @@ be reused later by a WASM adapter
|
|||||||
| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits |
|
| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits |
|
||||||
| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values |
|
| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values |
|
||||||
| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` |
|
| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` |
|
||||||
| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates |
|
| `pricing/pricing.go` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload |
|
||||||
| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation |
|
| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap |
|
||||||
| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream |
|
| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" |
|
||||||
|
| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize |
|
||||||
|
| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth |
|
||||||
|
| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter |
|
||||||
|
|
||||||
## Request body → parser dispatch
|
## Request body → parser dispatch
|
||||||
|
|
||||||
@@ -186,11 +188,9 @@ response leg, covering both Bedrock body shapes:
|
|||||||
`totalTokens`). `firstNonZero` folds the two naming conventions into one
|
`totalTokens`). `firstNonZero` folds the two naming conventions into one
|
||||||
`Usage`; when Converse omits `totalTokens` the parser sums the buckets.
|
`Usage`; when Converse omits `totalTokens` the parser sums the buckets.
|
||||||
|
|
||||||
`ProviderName()` returns `"bedrock"` — its own pricing surface in the table
|
`ProviderName()` returns `"bedrock"` — its own `defaults_pricing.yaml` block,
|
||||||
management ships, keyed by the **normalised** model id (region prefix + version
|
keyed by the **normalised** model id (region prefix + version suffix stripped by
|
||||||
suffix stripped by the request parser; management normalises its keys the same
|
the request parser). `ParseResponse` returns `ErrStreamingUnsupported` for an
|
||||||
way at synth time so the two compare equal). `ParseResponse` returns
|
|
||||||
`ErrStreamingUnsupported` for an
|
|
||||||
AWS binary event-stream content-type (`application/vnd.amazon.eventstream`,
|
AWS binary event-stream content-type (`application/vnd.amazon.eventstream`,
|
||||||
`isAWSEventStream`) so the caller routes to the streaming accumulator instead.
|
`isAWSEventStream`) so the caller routes to the streaming accumulator instead.
|
||||||
|
|
||||||
@@ -205,34 +205,11 @@ response body. Streaming accumulators live in the middleware package
|
|||||||
([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go))
|
([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go))
|
||||||
but use `llm.NewScanner` so the framing contract stays here.
|
but use `llm.NewScanner` so the framing contract stays here.
|
||||||
|
|
||||||
### Pricing table
|
### Pricing catalog
|
||||||
|
|
||||||
**Management is the sole pricing authority.** The proxy carries no embedded
|
`Table.Cost`
|
||||||
price list and reads no pricing file: the whole table arrives inside
|
([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go))
|
||||||
`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change
|
is the cost formula — most security-relevant math in this module:
|
||||||
is just another push — the chain rebuild constructs a fresh `Table`, so there
|
|
||||||
is nothing to reload
|
|
||||||
([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The
|
|
||||||
management side of the contract (catalog defaults, the operator's stored
|
|
||||||
per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the
|
|
||||||
management-side module guide; `cost_meter`'s wire shape is in
|
|
||||||
[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md).
|
|
||||||
|
|
||||||
`EntryJSON`
|
|
||||||
([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the
|
|
||||||
management→proxy contract — five USD-per-1k rates under `input_per_1k`,
|
|
||||||
`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`,
|
|
||||||
`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical
|
|
||||||
names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by
|
|
||||||
direct struct conversion rather than field-by-field copying (a new rate can't
|
|
||||||
be silently dropped in transit).
|
|
||||||
|
|
||||||
`EntryCosts`
|
|
||||||
([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go))
|
|
||||||
is the cost formula — most security-relevant math in this module. The
|
|
||||||
**surface** (the `llm.provider` value the request parser stamped) selects the
|
|
||||||
formula, never the tier the entry came from: a per-provider-record override on
|
|
||||||
an Anthropic route still bills its cache buckets additively.
|
|
||||||
|
|
||||||
| Provider | Formula |
|
| Provider | Formula |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -241,7 +218,7 @@ an Anthropic route still bills its cache buckets additively.
|
|||||||
| default | `inTokens × InputPer1K + outTokens × OutputPer1K` |
|
| default | `inTokens × InputPer1K + outTokens × OutputPer1K` |
|
||||||
|
|
||||||
`bedrock` shares the Anthropic additive-cache formula
|
`bedrock` shares the Anthropic additive-cache formula
|
||||||
([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)):
|
([pricing.go:172-174](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||||
Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic
|
Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic
|
||||||
Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces
|
Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces
|
||||||
to `input + output`.
|
to `input + output`.
|
||||||
@@ -249,12 +226,15 @@ to `input + output`.
|
|||||||
Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in
|
Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in
|
||||||
to discounts by setting the field.
|
to discounts by setting the field.
|
||||||
|
|
||||||
`Costs`
|
`Loader`
|
||||||
([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the
|
([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go))
|
||||||
per-request split. The four per-bucket fields are the base; `TotalUSD` and
|
overlays an optional `pricing.yaml` from data-dir on top of the go:embed
|
||||||
`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from
|
defaults. Atomic pointer swap means readers never observe a partial update.
|
||||||
the breakdown. `InputUSD` is always the non-cached input bucket on both
|
The mtime-poll reloader (30s default cadence) keeps the previous table on
|
||||||
provider shapes, so input and cached-input never double-count.
|
parse failure so cost annotation never goes blank during a botched edit.
|
||||||
|
|
||||||
|
`defaults_pricing.yaml` is the source of truth for built-in pricing.
|
||||||
|
Operator overrides only carry the entries they want to change.
|
||||||
|
|
||||||
## Public contracts
|
## Public contracts
|
||||||
|
|
||||||
@@ -284,38 +264,29 @@ Order matters: `DetectFromURL` ties resolve by registration order.
|
|||||||
`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat
|
`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat
|
||||||
them as wire-stable — new providers must take fresh numbers.
|
them as wire-stable — new providers must take fresh numbers.
|
||||||
|
|
||||||
**`Pricing` construction + lookup**
|
**`Pricing` lookup**
|
||||||
([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)):
|
([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error)
|
|
||||||
func NewTable(raw map[string]map[string]EntryJSON) (*Table, error)
|
|
||||||
|
|
||||||
func (t *Table) Lookup(provider, model string) (Entry, bool)
|
|
||||||
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool)
|
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool)
|
||||||
func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool)
|
|
||||||
func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw
|
Nil-safe: `t.Cost` on a nil receiver returns `(0, false)`
|
||||||
two-level map `cost_meter` uses for the per-provider-record tier (it looks up an
|
([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)).
|
||||||
`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both
|
`ok=false` means provider or model is absent from the loaded table; the caller
|
||||||
reject any non-finite or negative rate, so a corrupt config fails the chain
|
emits `cost.skipped=unknown_model`.
|
||||||
build rather than mispricing silently. Nil input yields an empty,
|
|
||||||
never-matching table.
|
|
||||||
|
|
||||||
Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false`
|
|
||||||
([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)).
|
|
||||||
`ok=false` means the surface or model is absent from the table management sent;
|
|
||||||
the caller emits `cost.skipped=unknown_model`.
|
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
1. **The pricing package is pure and platform-independent.** No file I/O, no
|
1. **Cross-platform pricing build.** `pricing_unix.go` carries the only
|
||||||
`//go:embed`, no goroutines, no build tags — the rates arrive as config, so
|
functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an
|
||||||
there is nothing platform-specific left to port. Anything reintroducing a
|
open descriptor — both Unix-only). `pricing_other.go` is a build-tag
|
||||||
read-from-disk path here re-splits pricing authority between management and
|
fallback that returns `"not supported on this platform"`
|
||||||
the proxy, which is exactly what this design removed.
|
([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)).
|
||||||
|
The proxy is Linux-only in production today; a Windows port needs an
|
||||||
|
equivalent path-as-handle implementation. Reviewers building on Windows
|
||||||
|
should expect this surface to return an error at startup if an override
|
||||||
|
file is configured.
|
||||||
|
|
||||||
2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end
|
2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end
|
||||||
in `\n\n` still yields its accumulated event before `io.EOF`
|
in `\n\n` still yields its accumulated event before `io.EOF`
|
||||||
@@ -327,45 +298,38 @@ the caller emits `cost.skipped=unknown_model`.
|
|||||||
usage rather than aborting
|
usage rather than aborting
|
||||||
([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)).
|
([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)).
|
||||||
|
|
||||||
3. **Management is the only source of rates.** `Table` has no constructor that
|
3. **`defaults_pricing.yaml` is the source of truth.** Compiled into the
|
||||||
invents prices: the only way in is `NewTable`/`NewEntries` over the wire map
|
binary via `//go:embed`
|
||||||
management sent. A missing or empty `pricing` block therefore means *no
|
([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)).
|
||||||
prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) —
|
`DefaultTable()` parses once and panics on parse failure
|
||||||
never a stale built-in fallback that would silently bill list price.
|
([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go))
|
||||||
|
— by design: a broken embedded YAML must not ship to production.
|
||||||
|
|
||||||
4. **Tables are immutable once built.** `Table.entries` is written only in
|
4. **Loader path validation.** `resolveMiddlewareDataPath`
|
||||||
`NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord`
|
([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go))
|
||||||
map is likewise build-time-only
|
rejects absolute paths, traversal segments, and basenames that fail
|
||||||
([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This
|
`basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain
|
||||||
is what makes the no-reload design safe: a price change arrives as a mapping
|
inside `baseDir` even after `filepath.Clean`. Tests:
|
||||||
push that builds a new middleware instance over a new table, so concurrent
|
`TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`,
|
||||||
readers can't observe a half-updated price list and no atomic swap or lock
|
`TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`.
|
||||||
is needed on the hot path.
|
|
||||||
|
|
||||||
5. **Rate validation happens at chain-build time, not per request.**
|
5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the
|
||||||
`NewEntries` rejects negative, NaN, and ±Inf rates field by field
|
open descriptor (never re-stat by path), `info.Mode().IsRegular()` check,
|
||||||
([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming
|
`io.LimitReader(f, maxPricingBytes+1)` with a final size assertion
|
||||||
the offending surface/model/field in the error. Management enforces the same
|
([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)).
|
||||||
constraints at its API boundary and in its YAML parser, so this is
|
A mid-read symlink swap is detected because the fstat is on the original
|
||||||
defense-in-depth — but it means a corrupt push fails loudly at build instead
|
fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`.
|
||||||
of producing negative costs on live traffic. Test:
|
|
||||||
`TestNewTable_ValidatesRates`.
|
|
||||||
|
|
||||||
6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's
|
6. **`yaml.NewDecoder(...).KnownFields(true)`**
|
||||||
`pricing.Entry` together.** `NewEntries` converts by direct struct
|
([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go))
|
||||||
conversion `Entry(e)`
|
rejects YAML files that carry fields not in the schema. A typo in an
|
||||||
([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which
|
operator override file fails loud instead of silently zeroing rates.
|
||||||
only compiles while the two structs stay field-identical — so the proxy half
|
|
||||||
is compiler-enforced. The management half is not: a rate added there but not
|
|
||||||
here unmarshals into nothing and prices that bucket at `InputPer1K`.
|
|
||||||
|
|
||||||
## Things to scrutinise
|
## Things to scrutinise
|
||||||
|
|
||||||
**Correctness.** Verify the OpenAI cached-prompt clamp at
|
**Correctness.** Verify OpenAI cached-prompt clamp at
|
||||||
[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go)
|
[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go)
|
||||||
short-circuits before subtraction. Negative token counts are clamped to zero up
|
short-circuits before subtraction. `Anthropic.TotalTokens` sums all four
|
||||||
front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so
|
|
||||||
no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four
|
|
||||||
buckets (in + out + cache_read + cache_creation) — downstream dashboards
|
buckets (in + out + cache_read + cache_creation) — downstream dashboards
|
||||||
need to know this differs from `input + output`.
|
need to know this differs from `input + output`.
|
||||||
`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a
|
`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a
|
||||||
@@ -374,27 +338,22 @@ noting).
|
|||||||
|
|
||||||
**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event
|
**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event
|
||||||
errors from `Scanner.Next` and both accumulators stop with partial usage.
|
errors from `Scanner.Next` and both accumulators stop with partial usage.
|
||||||
Pricing is no longer file-backed, so the loader's path-traversal / symlink /
|
Pricing file 1 MiB cap is orders of magnitude larger than realistic. Confirm
|
||||||
oversize surface is gone entirely — the config channel (an authenticated
|
new schema additions are mirrored in both `pricingFile` and `Entry`;
|
||||||
mapping push from management) is now the only way rates enter the proxy, and
|
`KnownFields(true)` will reject silently-typo'd operator overrides
|
||||||
`NewEntries` is the validation boundary on it. A new rate added to management's
|
otherwise.
|
||||||
`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing
|
|
||||||
path (see invariant 6).
|
|
||||||
|
|
||||||
**Concurrency.** Nothing in this package is shared mutable state: tables are
|
**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never
|
||||||
built once and never written again, so `cost_meter`'s hot path is lock-free by
|
block or see a torn table. `Loader.Reload` is one goroutine, cancelled via
|
||||||
construction rather than by atomic swap. Per-call `Scanner` instances mean no
|
context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()`
|
||||||
shared state across concurrent response-parser calls.
|
uses `sync.Once`. Per-call `Scanner` instances mean no shared state across
|
||||||
|
concurrent response-parser calls.
|
||||||
|
|
||||||
**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the
|
**Perf.** `Table.Cost` is two map lookups + multiplications, O(1).
|
||||||
per-provider-record tier adds at most one more lookup. `Scanner.Next` is one
|
`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s.
|
||||||
`ReadString('\n')` per line. No background goroutines and no per-request
|
|
||||||
allocation of pricing state.
|
|
||||||
|
|
||||||
**Observability.** A config carrying no `pricing` block logs one warning at
|
**Observability.** Reload failures count via `metric.Int64Counter` keyed
|
||||||
chain-build time (`cost_meter` factory) and then records
|
`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood.
|
||||||
`cost.skipped=unknown_model` per request, so an old-management deployment is
|
|
||||||
visible in both logs and the access log rather than quietly reporting $0.
|
|
||||||
Parser errors return sentinels — middleware uses `errors.Is` to map to the
|
Parser errors return sentinels — middleware uses `errors.Is` to map to the
|
||||||
right `cost.skipped` reason.
|
right `cost.skipped` reason.
|
||||||
|
|
||||||
@@ -406,7 +365,7 @@ right `cost.skipped` reason.
|
|||||||
| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays |
|
| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays |
|
||||||
| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays |
|
| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays |
|
||||||
| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection |
|
| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection |
|
||||||
| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table |
|
| `pricing/pricing_test.go` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation |
|
||||||
|
|
||||||
**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)):
|
**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)):
|
||||||
`openai_chat_completion.json` (chat.completions with usage),
|
`openai_chat_completion.json` (chat.completions with usage),
|
||||||
@@ -414,15 +373,14 @@ right `cost.skipped` reason.
|
|||||||
`openai_stream.txt` (3 deltas + usage + `[DONE]`),
|
`openai_stream.txt` (3 deltas + usage + `[DONE]`),
|
||||||
`anthropic_messages.json` (Messages API non-streaming),
|
`anthropic_messages.json` (Messages API non-streaming),
|
||||||
`anthropic_stream.txt` (full 7-event sequence: message_start →
|
`anthropic_stream.txt` (full 7-event sequence: message_start →
|
||||||
content_block_{start,delta×2,stop} → message_delta (usage) → message_stop).
|
content_block_{start,delta×2,stop} → message_delta (usage) → message_stop),
|
||||||
No pricing fixture: the table is config-delivered, so pricing tests construct
|
`pricing.yaml` (realistic-pricing starter for operator overrides).
|
||||||
it in-process from a wire-shape map.
|
|
||||||
|
|
||||||
## Cross-references
|
## Cross-references
|
||||||
|
|
||||||
- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
||||||
— the chain that calls `llm.Parsers()`, `llm.ParserByName`,
|
— the chain that calls `llm.Parsers()`, `llm.ParserByName`,
|
||||||
`llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`.
|
`llm.NewScanner`, `pricing.NewLoader`.
|
||||||
- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the
|
- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the
|
||||||
Bedrock AWS event-stream accumulator:
|
Bedrock AWS event-stream accumulator:
|
||||||
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# proxy/runtime — translate + serve + log
|
# proxy/runtime — translate + serve + log
|
||||||
|
|
||||||
> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target.
|
> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target.
|
||||||
> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config.
|
> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path.
|
||||||
|
|
||||||
## Module boundary
|
## Module boundary
|
||||||
|
|
||||||
@@ -114,7 +114,8 @@ At **request time** the access-log middleware stamps `CapturedData`; the auth ch
|
|||||||
|
|
||||||
## Public contracts touched
|
## Public contracts touched
|
||||||
|
|
||||||
- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client.
|
- `proxy.Server.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241).
|
||||||
|
- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250).
|
||||||
- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56).
|
- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56).
|
||||||
- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default.
|
- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default.
|
||||||
- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258).
|
- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258).
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ strips the `@version` suffix from the model, and maps the publisher to a parser
|
|||||||
surface via `vertexPublisherVendor`:
|
surface via `vertexPublisherVendor`:
|
||||||
|
|
||||||
- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic
|
- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic
|
||||||
parser, priced under the **`anthropic`** surface of the pricing table
|
parser, priced under the **`anthropic`** block in `defaults_pricing.yaml`
|
||||||
management ships (the parser emits the standard Anthropic provider label, so
|
(the parser emits the standard Anthropic provider label, so Vertex Claude
|
||||||
Vertex Claude reuses first-party Anthropic prices).
|
reuses first-party Anthropic prices).
|
||||||
- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup
|
- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup
|
||||||
today).
|
today).
|
||||||
- anything else (notably `google` / Gemini) → empty vendor → **no parser**.
|
- anything else (notably `google` / Gemini) → empty vendor → **no parser**.
|
||||||
@@ -104,9 +104,8 @@ is omitted from the catalog.
|
|||||||
|
|
||||||
> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price
|
> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price
|
||||||
> premium that the base per-token rates do **not** model — cost annotations for
|
> premium that the base per-token rates do **not** model — cost annotations for
|
||||||
> those regions read low. Operators who need exact regional billing set the
|
> those regions read low. Operators who need exact regional billing override
|
||||||
> affected models' prices on the provider record, or replace the default entries
|
> the affected entries in `pricing.yaml`.
|
||||||
> via management's `AgentNetwork.PricingDefaultsFile`.
|
|
||||||
|
|
||||||
## AWS Bedrock (`bedrock_api`)
|
## AWS Bedrock (`bedrock_api`)
|
||||||
|
|
||||||
@@ -212,19 +211,15 @@ so a model-listing call can't be rewritten onto an upstream that would 404 it.
|
|||||||
## Catalog ↔ pricing cross-check
|
## Catalog ↔ pricing cross-check
|
||||||
|
|
||||||
Catalog prices and context windows are cross-checked against LiteLLM's
|
Catalog prices and context windows are cross-checked against LiteLLM's
|
||||||
`model_prices_and_context_window.json`. The **catalog is the source of default
|
`model_prices_and_context_window.json`. The proxy's embedded
|
||||||
prices**: management's `pricing.DefaultTable` folds every catalog provider's
|
`defaults_pricing.yaml` covers **every metered first-party model** the catalog
|
||||||
models into the surfaces that provider declares (`PricingSurfaces`), so coverage
|
enumerates — guarded by
|
||||||
is structural rather than maintained in a parallel file
|
`TestDefaultTable_FirstPartyModelCoverage`
|
||||||
([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)).
|
([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)),
|
||||||
`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up
|
which fails if a catalog model has no embedded price. Bedrock entries are keyed
|
||||||
unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two
|
by the **normalised** id the request parser emits (region prefix + version
|
||||||
providers contribute the same (surface, model) at different rates. Bedrock
|
suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices
|
||||||
entries are keyed by the **normalised** id the request parser emits (region
|
straight off the `anthropic` block.
|
||||||
prefix + version suffix stripped) — management applies the same normalisation to
|
|
||||||
per-provider prices at synth time, so the two keys compare equal. Vertex Claude
|
|
||||||
carries no Bedrock-style prefix, so it prices straight off the `anthropic`
|
|
||||||
surface.
|
|
||||||
|
|
||||||
## Things to scrutinise
|
## Things to scrutinise
|
||||||
|
|
||||||
@@ -237,17 +232,16 @@ operator-misconfigured Vertex provider and unmetered Gemini traffic; verify
|
|||||||
publishers).
|
publishers).
|
||||||
|
|
||||||
**Correctness.** `normalizeBedrockModel` is the join between the wire id and the
|
**Correctness.** `normalizeBedrockModel` is the join between the wire id and the
|
||||||
pricing key — a model that normalises to something absent from the shipped
|
pricing key — a model that normalises to something not in `defaults_pricing.yaml`
|
||||||
pricing table meters at `cost.skipped=unknown_model` rather than failing the
|
meters at `cost.skipped=unknown_model` rather than failing the request. The
|
||||||
request. The
|
|
||||||
`/bedrock` prefix strip must run on both the parser side (so the model is
|
`/bedrock` prefix strip must run on both the parser side (so the model is
|
||||||
extracted) and the router side (so the upstream path is native); a regression in
|
extracted) and the router side (so the upstream path is native); a regression in
|
||||||
either silently breaks the other.
|
either silently breaks the other.
|
||||||
|
|
||||||
**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a
|
**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a
|
||||||
~10% premium not modelled by base pricing — flagged in the catalog comment.
|
~10% premium not modelled by base pricing — flagged in both the catalog comment
|
||||||
Operators needing exact regional billing set per-provider prices on the model
|
and `defaults_pricing.yaml`. Operators needing exact regional billing override
|
||||||
rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`).
|
the relevant entries.
|
||||||
|
|
||||||
## Cross-references
|
## Cross-references
|
||||||
|
|
||||||
|
|||||||
@@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) {
|
|||||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||||
|
|
||||||
|
// A cluster different from the pinned one must be rejected; echoing the
|
||||||
|
// pinned one back is valid.
|
||||||
|
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr("attacker.cluster.invalid"),
|
||||||
|
EnableLogCollection: before.EnableLogCollection,
|
||||||
|
EnablePromptCollection: before.EnablePromptCollection,
|
||||||
|
RedactPii: before.RedactPii,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
// Restore the original toggles.
|
// Restore the original toggles.
|
||||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr(before.Cluster),
|
||||||
EnableLogCollection: before.EnableLogCollection,
|
EnableLogCollection: before.EnableLogCollection,
|
||||||
EnablePromptCollection: before.EnablePromptCollection,
|
EnablePromptCollection: before.EnablePromptCollection,
|
||||||
RedactPii: before.RedactPii,
|
RedactPii: before.RedactPii,
|
||||||
|
|||||||
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package agentnetwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/e2e/harness"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// harnessStartFresh boots a dedicated combined server with its own fresh
|
||||||
|
// account and registers its teardown on t.
|
||||||
|
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
|
||||||
|
t.Helper()
|
||||||
|
fresh, err := harness.StartCombined(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
|
||||||
|
if _, err := fresh.Bootstrap(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fresh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
|
||||||
|
// account that has never been bootstrapped: the GET reads as the defaults
|
||||||
|
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
|
||||||
|
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
|
||||||
|
// pins it immutably. The shared srv cannot provide that starting state (any
|
||||||
|
// provider-creating test bootstraps it, and test order is deliberately not
|
||||||
|
// relied on), so this boots a dedicated combined server — the image is
|
||||||
|
// already built and cached by TestMain's StartCombined, so the extra cost is
|
||||||
|
// one container start.
|
||||||
|
func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fresh, err := harnessStartFresh(ctx, t)
|
||||||
|
require.NoError(t, err, "start dedicated combined server")
|
||||||
|
|
||||||
|
// Before agent-network bootstrap the settings read as the defaults, not
|
||||||
|
// as an error and not as a null body.
|
||||||
|
before, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings on a fresh account must succeed")
|
||||||
|
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
|
||||||
|
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
|
||||||
|
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
|
||||||
|
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||||
|
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
|
||||||
|
|
||||||
|
// A PUT without a cluster has nothing to pin the account to.
|
||||||
|
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
EnableLogCollection: true,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
|
// A PUT carrying a cluster bootstraps the account and applies the
|
||||||
|
// mutable fields from the same request. Every toggle is set away from
|
||||||
|
// its bootstrap default so each assertion can actually fail.
|
||||||
|
const cluster = "e2e.bootstrap.netbird.selfhosted"
|
||||||
|
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr(cluster),
|
||||||
|
EnableLogCollection: false,
|
||||||
|
EnablePromptCollection: true,
|
||||||
|
RedactPii: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "bootstrap settings via PUT must succeed")
|
||||||
|
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
|
||||||
|
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
|
||||||
|
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
|
||||||
|
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
|
||||||
|
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
|
||||||
|
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
|
||||||
|
|
||||||
|
// The row is persisted: an independent read agrees on every field.
|
||||||
|
after, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings after bootstrap must succeed")
|
||||||
|
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
|
||||||
|
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
|
||||||
|
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
|
||||||
|
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
|
||||||
|
|
||||||
|
// Once bootstrapped, later updates may omit the cluster entirely.
|
||||||
|
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
EnableLogCollection: true,
|
||||||
|
EnablePromptCollection: false,
|
||||||
|
RedactPii: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
|
||||||
|
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
|
||||||
|
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
|
||||||
|
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
|
||||||
|
|
||||||
|
// The cluster is immutable: a different value is rejected rather than
|
||||||
|
// silently ignored, and the rejected update must not disturb anything.
|
||||||
|
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr("other.cluster.invalid"),
|
||||||
|
EnableLogCollection: false,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
|
final, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
|
||||||
|
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
|
||||||
|
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
|
||||||
|
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
|
||||||
|
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
|
||||||
|
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
|
||||||
|
}
|
||||||
12
funding.json
12
funding.json
@@ -6,7 +6,7 @@
|
|||||||
"name": "NetBird GmbH",
|
"name": "NetBird GmbH",
|
||||||
"email": "hello@netbird.io",
|
"email": "hello@netbird.io",
|
||||||
"phone": "",
|
"phone": "",
|
||||||
"description": "NetBird GmbH is a Berlin-based software company specializing in the development of open source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.",
|
"description": "NetBird GmbH is a Berlin-based software company specializing in the development of open-source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open-source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.",
|
||||||
"webpageUrl": {
|
"webpageUrl": {
|
||||||
"url": "https://github.com/netbirdio"
|
"url": "https://github.com/netbirdio"
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
{
|
{
|
||||||
"guid": "netbird",
|
"guid": "netbird",
|
||||||
"name": "NetBird",
|
"name": "NetBird",
|
||||||
"description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.",
|
"description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open-source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.",
|
||||||
"webpageUrl": {
|
"webpageUrl": {
|
||||||
"url": "https://github.com/netbirdio/netbird"
|
"url": "https://github.com/netbirdio/netbird"
|
||||||
},
|
},
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"guid": "support-yearly",
|
"guid": "support-yearly",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"name": "Support Open Source Development and Maintenance - Yearly",
|
"name": "Support Open Source Development and Maintenance - Yearly",
|
||||||
"description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.",
|
"description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.",
|
||||||
"amount": 100000,
|
"amount": 100000,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"frequency": "yearly",
|
"frequency": "yearly",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"guid": "support-one-time-year",
|
"guid": "support-one-time-year",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"name": "Support Open Source Development and Maintenance - One Year",
|
"name": "Support Open Source Development and Maintenance - One Year",
|
||||||
"description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.",
|
"description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.",
|
||||||
"amount": 100000,
|
"amount": 100000,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"frequency": "one-time",
|
"frequency": "one-time",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"guid": "support-one-time-monthly",
|
"guid": "support-one-time-monthly",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"name": "Support Open Source Development and Maintenance - Monthly",
|
"name": "Support Open Source Development and Maintenance - Monthly",
|
||||||
"description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.",
|
"description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.",
|
||||||
"amount": 10000,
|
"amount": 10000,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"frequency": "monthly",
|
"frequency": "monthly",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"guid": "support-monthly",
|
"guid": "support-monthly",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"name": "Support Open Source Development and Maintenance - One Month",
|
"name": "Support Open Source Development and Maintenance - One Month",
|
||||||
"description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.",
|
"description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.",
|
||||||
"amount": 10000,
|
"amount": 10000,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"frequency": "monthly",
|
"frequency": "monthly",
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -114,7 +114,7 @@ require (
|
|||||||
github.com/ti-mo/conntrack v0.5.1
|
github.com/ti-mo/conntrack v0.5.1
|
||||||
github.com/ti-mo/netfilter v0.5.2
|
github.com/ti-mo/netfilter v0.5.2
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||||
github.com/yusufpapurcu/wmi v1.2.4
|
github.com/yusufpapurcu/wmi v1.2.4
|
||||||
github.com/zcalusic/sysinfo v1.1.3
|
github.com/zcalusic/sysinfo v1.1.3
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||||
@@ -339,3 +339,5 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
|
|||||||
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||||
|
|
||||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||||
|
|
||||||
|
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -490,6 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
|||||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||||
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
|
||||||
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
@@ -660,8 +662,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
|||||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
@@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
|||||||
Return(true, context.Background(), nil).
|
Return(true, context.Background(), nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
|
|
||||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
// Swallow activity events so the mutation paths (create/update/delete)
|
||||||
|
// are exercisable through the HTTP layer.
|
||||||
|
accounts := account.NewMockManager(ctrl)
|
||||||
|
accounts.EXPECT().
|
||||||
|
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
AnyTimes()
|
||||||
|
accounts.EXPECT().
|
||||||
|
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
AnyTimes()
|
||||||
|
|
||||||
|
manager := agentnetwork.NewManager(st, perms, accounts, nil)
|
||||||
h := &handler{manager: manager}
|
h := &handler{manager: manager}
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
|
||||||
|
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
|
||||||
|
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
|
||||||
h.addPolicyEndpoints(router)
|
h.addPolicyEndpoints(router)
|
||||||
h.addConsumptionEndpoints(router)
|
h.addConsumptionEndpoints(router)
|
||||||
h.addBudgetRuleEndpoints(router)
|
h.addBudgetRuleEndpoints(router)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
nethttp "net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) {
|
|||||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
|
||||||
|
// with the other PUT endpoints: the request replaces the provider's mutable
|
||||||
|
// state, so optional fields absent from the JSON land as their zero values.
|
||||||
|
// The two exceptions are server-side: the api_key (a secret — omitted means
|
||||||
|
// "not rotated") and the session keypair, both preserved by the manager. The
|
||||||
|
// identity headers stay on the wire as explicit empty strings so a cleared
|
||||||
|
// value round-trips.
|
||||||
|
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
create := `{
|
||||||
|
"provider_id": "openai_api",
|
||||||
|
"name": "openai",
|
||||||
|
"upstream_url": "https://api.openai.com",
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"enabled": true,
|
||||||
|
"metadata_disabled": true,
|
||||||
|
"skip_tls_verification": true,
|
||||||
|
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
|
||||||
|
"identity_header_user_id": "x-bf-dim-netbird_user_id",
|
||||||
|
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
|
||||||
|
}`
|
||||||
|
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
|
||||||
|
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var created api.AgentNetworkProvider
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
|
||||||
|
|
||||||
|
// Minimal update: only the required fields, no api_key. Everything
|
||||||
|
// optional must land as its zero value.
|
||||||
|
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
|
||||||
|
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
|
||||||
|
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
|
||||||
|
|
||||||
|
var updated api.AgentNetworkProvider
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
|
||||||
|
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
|
||||||
|
assert.True(t, updated.Enabled, "sent field must apply")
|
||||||
|
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
|
||||||
|
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
|
||||||
|
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
|
||||||
|
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
|
||||||
|
assert.Empty(t, updated.Models, "omitted models must be cleared")
|
||||||
|
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
|
||||||
|
"cleared identity header must round-trip as an explicit empty string")
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user