Compare commits

...

9 Commits

Author SHA1 Message Date
Viktor Liu
5885fccb55 Add CrowdSec AppSec request inspection to the reverse proxy 2026-08-19 12:00:30 +02:00
Viktor Liu
2355d4947d Resolve the country once per check in allow_match any mode 2026-08-19 10:59:40 +02:00
Viktor Liu
11304900bb Preserve allow_match in restriction conversions when it is the only field set 2026-08-19 10:59:40 +02:00
Viktor Liu
22852230b5 Cover reverse proxy restrictions in the Postgres pgx read path with a test 2026-08-19 10:59:40 +02:00
Viktor Liu
4efadd1e33 Add allow_match any/all mode to reverse proxy access restrictions 2026-08-19 10:54:21 +02:00
Zoltan Papp
6d223042eb [client] Clear stale installer result before starting update (#7204)
* [client] Clear stale installer result before starting update

The installer result file could survive a previous update attempt (e.g.
when the updater wrote it after the restarted daemon already ran its
startup check). A new install attempt left the old file in place, so the
GUI progress window's first GetInstallerResult poll read the outdated
result: a stale success made the GUI quit mid-install, which cancelled
the TriggerUpdate context and aborted the artifact verification; a stale
error surfaced a bogus failure dialog for a succeeding update.

Remove any leftover result file at the start of RunInstallation, before
the download begins, so result watchers only see the current attempt's
outcome.

* [client] Align stale-result warning with log message style
2026-08-19 10:27:06 +02:00
Zoltan Papp
070a0a7bf1 [client, android] Handle network changes without restarting the engine (#7144)
On network changes the client restarted the whole engine. That is heavy-handed and slow: it tears down working state to recover from a transition the engine could handle itself. This replaces the restart with proper network event handling.

Suspend the retry loops while no network is available. Instead of burning through backoff intervals against an unreachable network, the reconnection loops park until the OS reports a usable network again.

Reconnect immediately on a network switch. When the OS hands us a new network, connections bound to the old one are swept and re-dialed right away, rather than waiting for a timeout to notice they are dead.
2026-08-19 10:05:12 +02:00
Zoltan Papp
ecfbd686b8 [client, android] Expose ssh functionality for Android (#7156)
Adds an SSHClient gomobile binding so the Android app can run an SSH session over the tunnel with a PTY, exposed through a listener interface for the in-app terminal.

Server type is auto-detected from the SSH banner, which selects the auth path: JWT device-code flow, NetBird key, or a regular server (NetBird key first, then password). Host keys are verified against the peer registry for NetBird servers and trust-on-first-use for regular ones.
2026-08-18 18:49:13 +02:00
Maycon Santos
d5b283dca8 [management] Refuse a usage limit a one-off setup key cannot honour (#7220)
refuse creating one-off keys without limits set to 1
2026-08-18 18:36:42 +02:00
99 changed files with 7766 additions and 1082 deletions

View File

@@ -26,6 +26,8 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
@@ -40,11 +42,6 @@ const (
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile
type ConnectionListener interface {
peer.Listener
}
// TunAdapter export internal TunAdapter for mobile
type TunAdapter interface {
device.TunAdapter
@@ -85,6 +82,13 @@ type Client struct {
deviceName string
uiVersion string
networkChangeListener listener.NetworkChangeListener
// netState outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
// ConnectClient, which distributes it to every reconnection loop.
netState *netstate.State
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
sweeper *netsweep.Sweeper
stateMu sync.RWMutex
connectClient *internal.ConnectClient
@@ -156,6 +160,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
recorder: peer.NewRecorder(""),
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
netState: netstate.New(),
sweeper: netsweep.New(),
}
}
@@ -196,7 +202,8 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
}
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.setState(cfg, cacheDir, cfgFile, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
@@ -237,7 +244,8 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -285,6 +293,24 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
}, nil
}
// SetNetworkAvailable feeds OS-reported network availability into the client.
// While unavailable, the internal reconnect loops suspend their attempts and
// the connection listener reports NoNetwork instead of Connecting; when
// availability returns, the loops resume immediately with a fresh backoff.
func (c *Client) SetNetworkAvailable(available bool) {
c.netState.Set(available)
c.recorder.SetNetworkAvailable(available)
}
// NotifyNetworkChange marks the management, signal and relay connections
// stale after the OS switched networks and schedules a sweep that cuts
// whatever has not redialed on the new network by then. The engine and the
// TUN device stay untouched.
func (c *Client) NotifyNetworkChange() {
c.sweeper.MarkNetworkChange()
log.Infof("network change: connections marked stale")
}
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine. anonymizeLevel is "default"
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
@@ -525,7 +551,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
c.recorder.SetConnectionListener(listener)
if listener == nil {
c.recorder.RemoveConnectionListener()
return
}
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
}
// RemoveConnectionListener remove connection listener

View File

@@ -0,0 +1,41 @@
//go:build android
package android
import (
"github.com/netbirdio/netbird/client/internal/peer"
)
// Client state values delivered via ConnectionListener.OnStateChanged,
// re-exported as basic constants so gomobile emits them into the generated
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
const (
ClientStateDisconnected = int(peer.ClientStateDisconnected)
ClientStateConnected = int(peer.ClientStateConnected)
ClientStateConnecting = int(peer.ClientStateConnecting)
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
)
// ConnectionListener export internal Listener for mobile. It mirrors
// peer.Listener with OnStateChanged taking a plain int (one of the
// ClientState* constants), because gomobile cannot bind named types.
type ConnectionListener interface {
OnStateChanged(state int)
OnConnected()
OnDisconnected()
OnConnecting()
OnDisconnecting()
OnAddressChanged(string, string)
OnPeersListChanged(int)
}
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
// peer.Listener, converting the typed state to the int the binding carries.
type connectionListenerAdapter struct {
ConnectionListener
}
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
a.ConnectionListener.OnStateChanged(int(state))
}

View File

@@ -191,40 +191,49 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
return nil
}
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
// but absent from the OAuthFlow interface, hence the assertion below — the same
// way internal/auth wires it in authenticateWithPKCEFlow.
type loginHintSetter interface {
SetLoginHint(hint string)
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
// choice to the IdP. Switching accounts is done by switching or removing
// profiles, not by logging out — logout keeps the email.
if a.cfgPath != "" {
if hint := readProfileEmail(a.cfgPath); hint != "" {
if setter, ok := oAuthFlow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
}
// profileLoginHint returns the stored account email for the profile at cfgPath.
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
// choice to the IdP. Switching accounts is done by switching or removing
// profiles, not by logging out — logout keeps the email.
func profileLoginHint(cfgPath string) string {
if cfgPath == "" {
return ""
}
return readProfileEmail(cfgPath)
}
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
// flow info, presents the verification URL through the opener and waits for
// the browser round-trip. Open is called synchronously — it is what marks the
// surface as opened on the client side, and a fast token's OnLoginSuccess is
// a no-op until it has, so the dismissal would be dropped rather than
// delayed. Openers must therefore not block: they post their UI work and
// return. onWaiting, when set, runs after the URL is shown, right before the
// blocking wait.
func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
flowInfo, err := flow.RequestAuthInfo(ctx)
if err != nil {
return nil, fmt.Errorf("request auth info: %w", err)
}
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
if onWaiting != nil {
onWaiting()
}
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
return nil, fmt.Errorf("wait for token: %w", err)
}
return &tokenInfo, nil

View File

@@ -0,0 +1,38 @@
//go:build android
package android
import (
"fmt"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
type prefsStore interface {
Get(namespace string, v any) (bool, error)
Put(namespace string, v any) error
}
type profilePrefs struct {
prefs *profilemanager.Prefs
}
func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
if configDir == "" || profileID == "" {
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
}
pm := NewProfileManager(configDir)
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
if err != nil {
return nil, fmt.Errorf("resolve profile prefs: %w", err)
}
return &profilePrefs{prefs: prefs}, nil
}
func (p *profilePrefs) Get(namespace string, v any) (bool, error) {
return p.prefs.Get(namespace, v)
}
func (p *profilePrefs) Put(namespace string, v any) error {
return p.prefs.Put(namespace, v)
}

View File

@@ -0,0 +1,649 @@
//go:build android
package android
import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
gossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/ssh/detection"
)
const (
sshDialTimeout = 30 * time.Second
sshDetectionTimeout = 5 * time.Second
)
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
// a string because gomobile flattens errors to their message, so a sentinel
// value would not survive the binding.
const PasswordRequiredMarker = "netbird-ssh-password-required"
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
// retry with TrustHostKey set. The presented fingerprint is appended after the
// marker so the prompt can display it and the retry can guard against a key
// that changed between the two connects. Only regular (non-NetBird) servers
// reach this: NetBird peers verify against the registry.
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
var (
errPasswordRequired = errors.New(PasswordRequiredMarker)
errClientClosed = errors.New("ssh client closed")
)
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
// marker message the Java side parses.
type errHostKeyUnknown struct {
fingerprint string
}
func (e *errHostKeyUnknown) Error() string {
return HostKeyUnknownMarker + ":" + e.fingerprint
}
// SSHTerminalListener receives SSH session events. It is implemented in Java.
//
// All callbacks are invoked from goroutines and may run concurrently with each
// other; the implementation must be safe to call from any thread.
type SSHTerminalListener interface {
OnConnected()
OnData(data []byte)
OnClose(reason string)
OnError(message string)
}
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
//
// It dials through the running NetBird tunnel and runs a standard SSH session
// on top with PTY enabled. Host-key verification uses the NetBird-provided
// peer SSH host keys, identical to the desktop client.
type SSHClient struct {
nb *Client
mu sync.Mutex
listener SSHTerminalListener
urlOpener URLOpener
sshClient *gossh.Client
session *gossh.Session
stdin io.WriteCloser
closed bool
// gen identifies the current connection attempt. Connect and Close bump it,
// so an in-flight dial or a reader left over from a previous connection
// finds itself stale and stays silent instead of publishing OnConnected or
// OnClose for a connection the caller already abandoned.
gen uint64
dialCancel context.CancelFunc
// knownHostsConfigDir and knownHostsProfile locate the TOFU store for
// regular SSH servers in the profile's preferences. Java supplies them,
// since an overlay IP is a different host under a different profile. Empty
// until set: without them a regular server cannot be verified and Connect
// refuses one.
knownHostsConfigDir string
knownHostsProfile string
// trustHostKey carries the fingerprint the user confirmed on a previous
// attempt, so the retry accepts exactly that key and persists it.
trustHostKey string
}
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
func NewSSHClient(c *Client) *SSHClient {
return &SSHClient{nb: c}
}
// SetListener registers the Java listener. Must be called before Connect to
// receive any events.
func (s *SSHClient) SetListener(l SSHTerminalListener) {
s.mu.Lock()
s.listener = l
s.mu.Unlock()
}
// SetURLOpener registers the Java URL opener used to display the device-code
// authorization page in a Custom Tabs window when the target peer requires
// JWT authentication. Must be set before Connect to be effective.
func (s *SSHClient) SetURLOpener(opener URLOpener) {
s.mu.Lock()
s.urlOpener = opener
s.mu.Unlock()
}
// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
// Must be set before connecting to a regular SSH server; without it such a
// server cannot be verified and Connect refuses one.
func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
s.mu.Lock()
s.knownHostsConfigDir = configDir
s.knownHostsProfile = profileID
s.mu.Unlock()
}
// TrustHostKey records the fingerprint the user confirmed for a regular server,
// so the next Connect accepts that exact key and adds it to the known-hosts
// store. Passing a fingerprint that no longer matches makes the connect fail
// rather than trust a key that changed since the prompt.
func (s *SSHClient) TrustHostKey(fingerprint string) {
s.mu.Lock()
s.trustHostKey = fingerprint
s.mu.Unlock()
}
// Connect dials the SSH server through the NetBird tunnel and performs the
// SSH handshake. It auto-detects the server type via SSH banner inspection
// and selects the appropriate authentication path:
//
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
// flow, opens the verification URL through the registered URLOpener, and
// uses the resulting token as the SSH password. Host-key verification
// uses the NetBird peer registry.
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
// private key. Host-key verification uses the NetBird peer registry.
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
// first (so a user-installed NetBird public key works), then falls back
// to the supplied password if non-empty. Host-key verification is
// trust-on-first-use against the per-profile known-hosts store.
//
// The password parameter is only consulted for regular SSH servers.
func (s *SSHClient) Connect(host string, port int, user, password string) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %d", port)
}
cfg, cfgPath, cc := s.nb.authSnapshot()
if cc == nil {
return errors.New("netbird client not running")
}
if cfg == nil {
return errors.New("netbird config not loaded")
}
engine := cc.Engine()
if engine == nil {
return errors.New("netbird engine not available")
}
s.mu.Lock()
s.gen++
gen := s.gen
s.mu.Unlock()
serverType := detectServerType(host, port)
log.Debugf("SSH server type: %s", serverType)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
if err != nil {
return err
}
clientConfig := &gossh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: sshDialTimeout,
}
err = s.dialAndHandshake(gen, host, port, clientConfig)
// An unknown host key is a prompt, not a failure: return the marker intact
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
var unknownHost *errHostKeyUnknown
if errors.As(err, &unknownHost) {
return errors.New(unknownHost.Error())
}
// A regular server may still accept a password, so let the caller ask for
// one instead of failing. NetBird servers never use a password, so a
// failure there is genuine.
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
passwordCouldHelp(err, password != "") {
return errPasswordRequired
}
if err != nil {
return rootCause(err)
}
return nil
}
// StartSession requests a PTY and starts an interactive shell. Output from
// the session is forwarded to the listener via OnData.
func (s *SSHClient) StartSession(cols, rows int) error {
err := s.startSession(cols, rows)
if err != nil {
log.Infof("SSH: start session failed: %v", err)
return rootCause(err)
}
return nil
}
// Write sends data to the SSH session stdin.
func (s *SSHClient) Write(data []byte) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return errors.New("ssh session not started")
}
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
// Resize updates the PTY window size.
func (s *SSHClient) Resize(cols, rows int) error {
s.mu.Lock()
session := s.session
s.mu.Unlock()
if session == nil {
return errors.New("ssh session not started")
}
return session.WindowChange(rows, cols)
}
// Reset makes a closed client usable for another Connect: Close leaves the
// one-shot guard set, and clearing it lets the same client back a reconnect.
func (s *SSHClient) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = false
}
// Close terminates the SSH session and underlying connection. Safe to call
// multiple times.
func (s *SSHClient) Close() error {
s.mu.Lock()
s.gen++
if s.dialCancel != nil {
s.dialCancel()
s.dialCancel = nil
}
sshClient := s.sshClient
session := s.session
stdin := s.stdin
s.sshClient = nil
s.session = nil
s.stdin = nil
notify := !s.closed
s.closed = true
listener := s.listener
s.mu.Unlock()
if stdin != nil {
if err := stdin.Close(); err != nil {
log.Debugf("ssh: stdin close: %v", err)
}
}
if session != nil {
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: session close: %v", err)
}
}
var firstErr error
if sshClient != nil {
if err := sshClient.Close(); err != nil {
firstErr = err
}
}
if notify && listener != nil {
listener.OnClose("closed by client")
}
return firstErr
}
func (s *SSHClient) startSession(cols, rows int) error {
log.Debugf("SSH: starting session %dx%d", cols, rows)
s.mu.Lock()
sshClient := s.sshClient
gen := s.gen
s.mu.Unlock()
if sshClient == nil {
return errors.New("ssh client not connected")
}
pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
if err != nil {
return err
}
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(pty.Session, "stale session")
return errClientClosed
}
s.session = pty.Session
s.stdin = pty.Stdin
s.mu.Unlock()
readerDone := make(chan string, 2)
go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
go func() {
reason := <-readerDone
if second := <-readerDone; reason == "" {
reason = second
}
s.notifyClose(gen, reason)
}()
log.Debug("SSH: session started, shell running")
return nil
}
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
switch serverType {
case detection.ServerTypeNetBirdJWT:
token, err := s.requestJWTToken(cfg, cfgPath)
if err != nil {
return nil, nil, fmt.Errorf("jwt: %w", err)
}
auths := []gossh.AuthMethod{gossh.Password(token)}
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
case detection.ServerTypeNetBirdNoJWT:
if cfg.SSHKey == "" {
return nil, nil, errors.New("no NetBird SSH key available")
}
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
if err != nil {
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
}
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
case detection.ServerTypeRegular:
var auths []gossh.AuthMethod
if cfg.SSHKey != "" {
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
auths = append(auths, gossh.PublicKeys(signer))
} else {
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
}
}
if password != "" {
pw := password
auths = append(auths, gossh.Password(pw))
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = pw
}
return answers, nil
}))
}
if len(auths) == 0 {
// Nothing to offer at all: ask for a password rather than failing,
// so the caller can retry once the user supplies one.
return nil, nil, errPasswordRequired
}
callback, err := s.tofuHostKeyCallback()
if err != nil {
return nil, nil, err
}
return auths, callback, nil
default:
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
}
}
// tofuHostKeyCallback verifies a regular server's host key against the
// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
// Java can show the fingerprint and, once confirmed, retry with the key
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
// has confirmed a fingerprint, the callback accepts exactly that key and
// appends it to the store.
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
s.mu.Lock()
configDir := s.knownHostsConfigDir
profileID := s.knownHostsProfile
trusted := s.trustHostKey
s.mu.Unlock()
if configDir == "" || profileID == "" {
return nil, errors.New("no known-hosts store configured for regular SSH")
}
store, err := openKnownHostsStore(configDir, profileID)
if err != nil {
return nil, fmt.Errorf("load known-hosts store: %w", err)
}
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
verdict, err := store.verify(hostname, remote, key)
if err != nil {
return err
}
if verdict == hostKeyMatched {
return nil
}
if verdict == hostKeyChanged {
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
}
fingerprint := gossh.FingerprintSHA256(key)
if trusted == "" {
return &errHostKeyUnknown{fingerprint: fingerprint}
}
if trusted != fingerprint {
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
}
if err := store.append(hostname, remote, key); err != nil {
return fmt.Errorf("persist trusted host key: %w", err)
}
// The confirmation is spent: now that the key is stored, a later
// reconnect must verify against the file, not re-accept this fingerprint.
s.mu.Lock()
s.trustHostKey = ""
s.mu.Unlock()
return nil
}, nil
}
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
s.mu.Lock()
urlOpener := s.urlOpener
s.mu.Unlock()
if urlOpener == nil {
return "", errors.New("URL opener not configured for JWT auth")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}
// The status callback covers the browser round-trip, which would
// otherwise leave the terminal blank.
tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
s.notifyStatus("Waiting for browser authentication...")
})
if err != nil {
return "", err
}
token := tokenInfo.GetTokenToUse()
if token == "" {
return "", errors.New("empty token returned by IdP")
}
// Tells the client the browser round-trip is over so it can dismiss the
// surface it opened, the same way the login and session-extend flows do.
// Without it the Custom Tab stays in front of the terminal even though the
// token has already been collected.
urlOpener.OnLoginSuccess()
return token, nil
}
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
defer cancel()
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
return errClientClosed
}
s.dialCancel = cancel
s.mu.Unlock()
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
if err != nil {
return err
}
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(client, "stale ssh client")
return errClientClosed
}
s.sshClient = client
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnConnected()
}
return nil
}
func (s *SSHClient) readLoop(r io.Reader, name string) string {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
chunk := make([]byte, n)
copy(chunk, buf[:n])
listener.OnData(chunk)
}
}
if err != nil {
// EOF is a normal shell exit, so report it without a reason.
if errors.Is(err, io.EOF) {
return ""
}
log.Debugf("ssh %s read: %v", name, err)
return rootCause(err).Error()
}
}
}
// notifyStatus writes a progress line to the terminal through the normal
// output path, so long steps are visible while nothing else is arriving.
func (s *SSHClient) notifyStatus(text string) {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
}
}
func (s *SSHClient) notifyClose(gen uint64, reason string) {
s.mu.Lock()
if gen != s.gen || s.closed {
s.mu.Unlock()
return
}
s.closed = true
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnClose(reason)
}
}
func closeQuiet(c io.Closer, label string) {
if c == nil {
return
}
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: close %s: %v", label, err)
}
}
func detectServerType(host string, port int) detection.ServerType {
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
defer cancel()
dialer := &net.Dialer{}
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
if err != nil {
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
return detection.ServerTypeRegular
}
return serverType
}
// rootCause returns the innermost error of a %w chain, so the terminal shows
// "i/o timeout" rather than every layer that added context on the way up.
func rootCause(err error) error {
for {
// A joined error has no single root, so keep it as-is.
if _, ok := err.(interface{ Unwrap() []error }); ok {
return err
}
next := errors.Unwrap(err)
if next == nil {
return err
}
err = next
}
}
// isAuthFailure distinguishes credential rejection from dial, timeout and
// host-key errors, which retrying with a password would not fix.
func isAuthFailure(err error) bool {
if errors.Is(err, errPasswordRequired) {
return true
}
var partial *gossh.PartialSuccessError
if errors.As(err, &partial) {
return true
}
return strings.Contains(err.Error(), "unable to authenticate")
}
// passwordCouldHelp reports whether prompting for a password again can change
// the outcome. gossh lists a method under "attempted methods" only when the
// server offered it, so a supplied password that was never attempted means the
// server does not accept passwords and the real error should surface instead.
func passwordCouldHelp(err error, passwordOffered bool) bool {
if !passwordOffered {
return true
}
msg := err.Error()
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
}

View File

@@ -0,0 +1,168 @@
//go:build android
package android
import (
"bytes"
"net"
"strconv"
"strings"
"sync"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
const knownHostsNamespace = "ssh"
const (
hostKeyUnknown hostKeyVerdict = iota
hostKeyMatched
hostKeyChanged
)
var knownHostsMu sync.Mutex
type hostKeyVerdict uint8
type knownHostsSection struct {
KnownHosts []string `json:"knownHosts"`
}
type knownHostsStore struct {
prefs prefsStore
}
// RemoveKnownHost deletes every known-hosts entry for host:port from the
// profile's store, so a host trusted for a session that is being deleted does
// not linger. Java calls this only once no session targets that host, so a
// shared host stays trusted. A missing entry is not an error: the goal state
// is "absent".
func RemoveKnownHost(configDir, profileID, host string, port int) error {
store, err := openKnownHostsStore(configDir, profileID)
if err != nil {
return err
}
return store.removeHost(host, port)
}
func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) {
prefs, err := newProfilePrefs(configDir, profileID)
if err != nil {
return nil, err
}
return &knownHostsStore{prefs: prefs}, nil
}
func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) {
lines, err := st.lines()
if err != nil {
return hostKeyUnknown, err
}
targets := knownHostsTargets(hostname, remote)
verdict := hostKeyUnknown
for _, line := range lines {
pubKey, ok := knownHostsLineKey(line, targets)
if !ok {
continue
}
if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) {
return hostKeyMatched, nil
}
verdict = hostKeyChanged
}
return verdict, nil
}
func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error {
line := knownhosts.Line(knownHostsTargets(hostname, remote), key)
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
lines, err := st.lines()
if err != nil {
return err
}
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)})
}
func (st *knownHostsStore) removeHost(host string, port int) error {
target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
lines, err := st.lines()
if err != nil {
return err
}
kept := make([]string, 0, len(lines))
for _, line := range lines {
if knownHostsLineMatches(line, target) {
continue
}
kept = append(kept, line)
}
if len(kept) == len(lines) {
return nil
}
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept})
}
func (st *knownHostsStore) lines() ([]string, error) {
var section knownHostsSection
if _, err := st.prefs.Get(knownHostsNamespace, &section); err != nil {
return nil, err
}
return section.KnownHosts, nil
}
func knownHostsTargets(hostname string, remote net.Addr) []string {
targets := []string{knownhosts.Normalize(hostname)}
if remote != nil {
if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] {
targets = append(targets, normalized)
}
}
return targets
}
func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return nil, false
}
_, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed))
if err != nil {
return nil, false
}
for _, host := range hosts {
for _, target := range targets {
if host == target {
return pubKey, true
}
}
}
return nil, false
}
// knownHostsLineMatches reports whether a known-hosts line's address list
// contains the normalized target. Comment and blank lines never match.
func knownHostsLineMatches(line, target string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return false
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
return false
}
for _, addr := range strings.Split(fields[0], ",") {
if addr == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,104 @@
//go:build android
package android
const (
sshSessionsNamespace = "ssh-sessions"
maxStoredSSHSessions = 50
)
type sshSessionRecord struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
}
type sshSessionsSection struct {
Sessions []sshSessionRecord `json:"sessions"`
}
// SSHSessionEntry is one stored SSH session, without any credential.
type SSHSessionEntry struct {
ID string
Host string
Port int
User string
}
// SSHSessionArray wraps stored SSH sessions for gomobile compatibility.
type SSHSessionArray struct {
items []*SSHSessionEntry
}
// NewSSHSessionArray creates an empty session array to fill via Add.
func NewSSHSessionArray() *SSHSessionArray {
return &SSHSessionArray{}
}
// Add appends a session entry, oldest first.
func (a *SSHSessionArray) Add(id, host string, port int, user string) {
a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user})
}
// Length returns the number of entries.
func (a *SSHSessionArray) Length() int {
return len(a.items)
}
// Get returns the entry at index i, or nil when out of range.
func (a *SSHSessionArray) Get(i int) *SSHSessionEntry {
if i < 0 || i >= len(a.items) {
return nil
}
return a.items[i]
}
// SSHSessionStore reads and writes a profile's stored SSH sessions.
type SSHSessionStore struct {
prefs prefsStore
}
// NewSSHSessionStore opens the session store of the given profile.
func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) {
prefs, err := newProfilePrefs(configDir, profileID)
if err != nil {
return nil, err
}
return &SSHSessionStore{prefs: prefs}, nil
}
// Load returns the stored sessions, oldest first.
func (s *SSHSessionStore) Load() (*SSHSessionArray, error) {
var section sshSessionsSection
if _, err := s.prefs.Get(sshSessionsNamespace, &section); err != nil {
return nil, err
}
out := NewSSHSessionArray()
for _, record := range section.Sessions {
if record.ID == "" || record.Host == "" {
continue
}
out.Add(record.ID, record.Host, record.Port, record.User)
}
return out, nil
}
// Save replaces the stored sessions, keeping only the newest entries when the
// list exceeds the storage cap.
func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error {
var items []*SSHSessionEntry
if sessions != nil {
items = sessions.items
}
if len(items) > maxStoredSSHSessions {
items = items[len(items)-maxStoredSSHSessions:]
}
records := make([]sshSessionRecord, 0, len(items))
for _, item := range items {
records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User})
}
return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records})
}

View File

@@ -21,7 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sshcommon "github.com/netbirdio/netbird/client/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
@@ -521,12 +521,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return err
}
storedKey, found := engine.GetPeerSSHKey(peerAddress)
if !found {
return sshcommon.ErrPeerNotFound
}
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
}
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool

View File

@@ -16,28 +16,47 @@ import (
"google.golang.org/grpc"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netsweep"
)
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
return grpc.WithContextDialer(dialContext)
}
// WithSweeper dials like WithCustomDialer but registers connections and
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
// dial options in order, so the later context dialer wins.
func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
if runtime.GOOS == "linux" {
currentUser, err := user.Current()
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
}
dial := sweeper.StartDial(ctx)
defer dial.Release()
// the custom dialer requires root permissions which are not required for use cases run as non-root
if currentUser.Uid != "0" {
log.Debug("Not running as root, using standard dialer")
dialer := &net.Dialer{}
return dialer.DialContext(ctx, "tcp", addr)
}
}
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
conn, err := dialContext(dial.Ctx(), addr)
if err != nil {
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
return nil, err
}
return conn, nil
return dial.WrapConn(conn)
})
}
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
if runtime.GOOS == "linux" {
currentUser, err := user.Current()
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
}
// the custom dialer requires root permissions which are not required for use cases run as non-root
if currentUser.Uid != "0" {
log.Debug("Not running as root, using standard dialer")
dialer := &net.Dialer{}
return dialer.DialContext(ctx, "tcp", addr)
}
}
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
}
return conn, nil
}

View File

@@ -3,6 +3,7 @@ package grpc
import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/util/wsproxy/client"
)
@@ -11,3 +12,8 @@ import (
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
return client.WithWebSocketDialer(tlsEnabled, component)
}
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
return grpc.EmptyDialOption{}
}

49
client/grpc/retry.go Normal file
View File

@@ -0,0 +1,49 @@
package grpc
import (
"context"
"errors"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/netbirdio/netbird/client/netstate"
)
// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
// OS network availability transitions: an operation cut down by a network
// change retries the moment the network settles instead of sleeping through
// the recovery. A nil netState never fires, leaving plain backoff.Retry
// behavior.
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error {
bo.Reset()
for {
err := operation()
if err == nil {
return nil
}
var permanent *backoff.PermanentError
if errors.As(err, &permanent) {
return permanent.Err
}
next := bo.NextBackOff()
if next == backoff.Stop {
if cerr := ctx.Err(); cerr != nil {
return cerr
}
return err
}
timer := time.NewTimer(next)
select {
case <-timer.C:
case <-netState.Changed():
timer.Stop()
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
}

91
client/grpc/retry_test.go Normal file
View File

@@ -0,0 +1,91 @@
package grpc
import (
"context"
"errors"
"testing"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/netstate"
)
func TestRetryWakesOnNetworkChange(t *testing.T) {
ns := netstate.New()
attempts := 0
operation := func() error {
attempts++
if attempts == 1 {
return errors.New("cut by network change")
}
return nil
}
go func() {
time.Sleep(20 * time.Millisecond)
ns.Set(false)
}()
start := time.Now()
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
require.NoError(t, err)
assert.Equal(t, 2, attempts, "network change must cause one immediate retry")
assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
}
func TestRetryPermanentError(t *testing.T) {
sentinel := errors.New("permission denied")
operation := func() error {
return backoff.Permanent(sentinel)
}
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries")
}
func TestRetryNilNetState(t *testing.T) {
attempts := 0
operation := func() error {
attempts++
if attempts < 3 {
return errors.New("transient")
}
return nil
}
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
require.NoError(t, err)
assert.Equal(t, 3, attempts, "nil network state must preserve timed retries")
}
func TestRetryStops(t *testing.T) {
failure := errors.New("still failing")
operation := func() error {
return failure
}
err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
assert.ErrorIs(t, err, failure, "stop backoff must return the operation error")
}
func TestRetryCtxCancelDuringSleep(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
operation := func() error {
return errors.New("failing")
}
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
start := time.Now()
err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop")
assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep")
}

View File

@@ -138,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
// This avoids creating a new connection to the management server
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
var flow OAuthFlow
var err error
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
if forceDeviceAuth {
flow, err = a.getDeviceFlow(client)
return err
deviceFlow, err := a.getDeviceFlow(client)
if err != nil {
return err
}
deviceFlow.SetLoginHint(hint)
flow = deviceFlow
return nil
}
// Try PKCE flow first
flow, err = a.getPKCEFlow(client)
pkceFlow, err := a.getPKCEFlow(client)
if err != nil {
// If PKCE not supported, try Device flow
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
flow, err = a.getDeviceFlow(client)
return err
deviceFlow, err := a.getDeviceFlow(client)
if err != nil {
return err
}
deviceFlow.SetLoginHint(hint)
flow = deviceFlow
return nil
}
return err
}
pkceFlow.SetLoginHint(hint)
flow = pkceFlow
return nil
})

View File

@@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
}
if hint != "" {
pkceFlowInfo.SetLoginHint(hint)
}
pkceFlowInfo.SetLoginHint(hint)
return pkceFlowInfo, nil
}
@@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
}
}
if hint != "" {
deviceFlowInfo.SetLoginHint(hint)
}
deviceFlowInfo.SetLoginHint(hint)
return deviceFlowInfo, nil
}

View File

@@ -38,6 +38,8 @@ import (
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ssh"
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
@@ -70,18 +72,42 @@ type ConnectClient struct {
updateManager *updater.Manager
persistSyncResponse bool
// netState gates every reconnection loop on OS-reported network
// availability. Nil (the default) disables gating; mobile platforms
// inject it via WithNetworkState.
netState *netstate.State
// sweeper cuts the management, signal and relay connections on network
// change; nil disables it.
sweeper *netsweep.Sweeper
}
// ConnectClientOption configures optional ConnectClient behavior.
type ConnectClientOption func(*ConnectClient)
// WithNetworkState injects the OS network availability state that gates every
// reconnection loop; without it gating is disabled.
func WithNetworkState(netState *netstate.State) ConnectClientOption {
return func(c *ConnectClient) { c.netState = netState }
}
// WithSweeper injects the network change sweeper.
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
return func(c *ConnectClient) { c.sweeper = sweeper }
}
func NewConnectClient(
ctx context.Context,
config *profilemanager.Config,
statusRecorder *peer.Status,
opts ...ConnectClientOption,
) *ConnectClient {
// Derive the run context here so Stop owns the cancel that unblocks the run
// loop. runCancel is set once at construction, so Stop can call it without
// racing the run loop's startup. Callers therefore need not cancel before Stop.
runCtx, runCancel := context.WithCancel(ctx)
return &ConnectClient{
c := &ConnectClient{
ctx: runCtx,
runCancel: runCancel,
runExited: make(chan struct{}),
@@ -89,6 +115,10 @@ func NewConnectClient(
statusRecorder: statusRecorder,
engineMutex: sync.Mutex{},
}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
@@ -274,6 +304,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return nil
}
// suspend connection attempts while the OS reports no usable network
if waited, err := c.netState.Wait(c.ctx); err != nil {
return nil
} else if waited {
backOff.Reset()
}
state.Set(StatusConnecting)
engineCtx, cancel := context.WithCancel(c.ctx)
@@ -285,7 +322,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}()
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
if err != nil {
// On daemon shutdown / Down() the parent context is cancelled
// and the dial fails with "context canceled". Wrapping that
@@ -360,7 +398,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}()
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
if err != nil {
log.Error(err)
return wrapErr(err)
@@ -396,7 +434,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
engineConfig.StateDir = filepath.Dir(path)
}
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
c.statusRecorder.SetRelayMgr(relayManager)
if len(relayURLs) > 0 {
if token != nil {
@@ -424,6 +463,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics,
MetricsCtx: c.ctx,
NetState: c.netState,
}, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse)
c.engine = engine
@@ -480,6 +520,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
// status stream stuck at Connecting.
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
if err != nil {
// Once the client context is cancelled backoff.WithContext surfaces the
// bare context error, and any attempt torn down mid-flight reports the
// same. That cancellation is the caller asking us to stop (Stop, Down or
// an engine restart), so exit cleanly instead of handing back a failure
// the caller would have to distinguish from a real one.
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
log.Info("exiting client retry loop, context cancelled")
return nil
}
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
@@ -673,7 +723,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
}
// connectToSignal creates Signal Service client and established a connection
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
var sigTLSEnabled bool
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
sigTLSEnabled = true
@@ -681,7 +731,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
sigTLSEnabled = false
}
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
if err != nil {
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)

View File

@@ -59,6 +59,7 @@ import (
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
"github.com/netbirdio/netbird/client/netstate"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
@@ -181,6 +182,9 @@ type EngineServices struct {
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
MetricsCtx context.Context
// NetState gates the reconnection loops on OS-reported network
// availability; nil disables gating.
NetState *netstate.State
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -204,6 +208,10 @@ type Engine struct {
config *EngineConfig
mobileDep MobileDependency
// netState gates the peer reconnection guards on OS-reported network
// availability; nil disables gating.
netState *netstate.State
// STUNs is a list of STUN servers used by ICE
STUNs []*stun.URI
// TURNs is a list of STUN servers used by ICE
@@ -337,6 +345,7 @@ func NewEngine(
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
netState: services.NetState,
STUNs: []*stun.URI{},
TURNs: []*stun.URI{},
networkSerial: 0,
@@ -1893,7 +1902,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive,
},
ICEConfig: e.createICEConfig(),
ICEConfig: e.createICEConfig(),
NetworkState: e.netState,
}
serviceDependencies := peer.ServiceDependencies{

View File

@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
@@ -93,6 +94,10 @@ type ConnConfig struct {
// ICEConfig ICE protocol configuration
ICEConfig icemaker.Config
// NetworkState gates the reconnection guard on OS-reported network
// availability; nil disables gating.
NetworkState *netstate.State
}
type Conn struct {
@@ -254,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
}
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
conn.wg.Add(1)
go func() {

View File

@@ -6,6 +6,8 @@ import (
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
)
// ConnStatus represents the connection state as seen by the guard.
@@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus
// - Relayed connection disconnected
// - ICE candidate changes
type Guard struct {
log *log.Entry
isConnectedOnAllWay connStatusFunc
timeout time.Duration
srWatcher *SRWatcher
log *log.Entry
isConnectedOnAllWay connStatusFunc
timeout time.Duration
srWatcher *SRWatcher
// netState gates reconnect attempts on OS-reported network availability;
// nil disables gating.
netState *netstate.State
relayedConnDisconnected chan struct{}
iCEConnDisconnected chan struct{}
}
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
// NewGuard creates a reconnection guard for a peer connection. A nil netState
// disables network availability gating.
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
return &Guard{
log: log,
isConnectedOnAllWay: isConnectedFn,
timeout: timeout,
srWatcher: srWatcher,
netState: netState,
relayedConnDisconnected: make(chan struct{}, 1),
iCEConnDisconnected: make(chan struct{}, 1),
}
@@ -96,9 +104,16 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
iceState := &iceRetryState{log: g.log}
defer iceState.reset()
netChanged := g.netState.Changed()
for {
select {
case <-tickerChannel:
// skip attempts while the OS reports no usable network; the
// netChanged case below resumes the loop once it returns
if !g.netState.IsOnline() {
continue
}
switch g.isConnectedOnAllWay() {
case ConnStatusConnected:
// all good, nothing to do
@@ -135,6 +150,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
tickerChannel = ticker.C
iceState.reset()
case <-netChanged:
// Re-arm for the next transition before acting on this one.
netChanged = g.netState.Changed()
if !g.netState.IsOnline() {
continue
}
// Ticks skipped while offline drove the backoff towards its
// maximum without ever attempting, and left the ICE budget
// frozen — possibly in hourly mode. Recover on our own so the
// peer does not depend on a signal or relay event that never
// comes when both stayed up across the outage.
g.log.Debugf("network is back, reset reconnection ticker")
ticker.Stop()
ticker = g.newReconnectTicker(ctx)
tickerChannel = ticker.C
iceState.reset()
case <-ctx.Done():
g.log.Debugf("context is done, stop reconnect loop")
return

View File

@@ -15,7 +15,7 @@ import (
func newTestGuard(status connStatusFunc) *Guard {
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
}
// countBackoffTickerGoroutines returns how many goroutines are currently sitting

View File

@@ -0,0 +1,107 @@
package guard
import (
"context"
"sync/atomic"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/netstate"
)
// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
// backoff must be able to grow well past the outage, as it does in production
// where the timeout is seconds to minutes.
func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard {
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState)
}
// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected
// across a network outage while neither signal nor relay reports an event —
// both stayed up, as on a short airplane mode toggle over Wi-Fi.
//
// Every tick taken while offline is skipped, but it still advances the
// exponential backoff, so by the time the network returns the next tick can be
// tens of seconds away. Without an explicit reaction to the transition the
// peer waits out that interval for a recovery that could start immediately.
func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) {
netState := netstate.New()
var attempts atomic.Int32
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start from the reconnect ticker (800ms initial interval), the state a
// peer is in after it loses its connection.
go g.Start(ctx, func() { attempts.Add(1) })
g.SetRelayedConnDisconnected()
// Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped
// while offline, but each one doubles the wait for the next.
netState.Set(false)
time.Sleep(8 * time.Second)
offlineAttempts := attempts.Load()
if offlineAttempts != 0 {
t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts)
}
netState.Set(true)
// The next organic tick is now several seconds out, so anything within
// this window can only come from reacting to the transition itself.
pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second)
defer stopPolling()
select {
case <-pollCtx.Done():
t.Fatal("peer was not retried within 2s of the network coming back, " +
"with neither a signal nor a relay event to fall back on")
case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }):
}
}
// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going
// offline must not itself trigger an attempt.
func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) {
netState := netstate.New()
var attempts atomic.Int32
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go g.Start(ctx, func() { attempts.Add(1) })
netState.Set(false)
time.Sleep(5 * time.Second)
if got := attempts.Load(); got != 0 {
t.Fatalf("callback ran %d times after going offline, want 0", got)
}
}
// pollUntil closes the returned channel once cond holds. It gives up when ctx
// is done, so the polling goroutine never outlives the test that started it.
func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} {
done := make(chan struct{})
go func() {
for {
if cond() {
close(done)
return
}
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Millisecond):
}
}
}()
return done
}

View File

@@ -1,11 +1,40 @@
package peer
// ClientState identifies the client connection state delivered via
// Listener.OnStateChanged.
type ClientState int
// Client states. The numeric values cross the gomobile boundary (the mobile
// bindings re-export them as integer constants), so they are a wire format:
// append new states at the end, never reorder or insert.
const (
ClientStateDisconnected ClientState = iota
ClientStateConnected
ClientStateConnecting
ClientStateDisconnecting
// ClientStateNoNetwork is an overlay state: it is never stored as the
// last notification, only derived from ClientStateConnecting while the
// OS reports no usable network (see notifier.effectiveState).
ClientStateNoNetwork
)
// Listener is a callback type about the NetBird network connection state
type Listener interface {
// OnStateChanged reports every client state transition. New states are
// delivered only through this callback; the per-state callbacks below
// are kept for compatibility and will be removed once all consumers
// have migrated.
OnStateChanged(state ClientState)
// Deprecated: consume OnStateChanged instead.
OnConnected()
// Deprecated: consume OnStateChanged instead.
OnDisconnected()
// Deprecated: consume OnStateChanged instead.
OnConnecting()
// Deprecated: consume OnStateChanged instead.
OnDisconnecting()
OnAddressChanged(string, string)
OnPeersListChanged(int)
}

View File

@@ -4,31 +4,64 @@ import (
"sync"
)
const (
stateDisconnected = iota
stateConnected
stateConnecting
stateDisconnecting
)
type notifier struct {
// publishLock orders state publication: it is held across computing the
// effective state and handing it to the listener, so a transition cannot
// overtake a newer one and leave the listener on a stale state.
publishLock sync.Mutex
serverStateLock sync.Mutex
listenersLock sync.Mutex
listener Listener
currentClientState bool
lastNotification int
lastNotification ClientState
lastNumberOfPeers int
lastFqdnAddress string
lastIPAddress string
networkAvailable bool
}
func newNotifier() *notifier {
return &notifier{}
return &notifier{
networkAvailable: true,
}
}
// effectiveState maps the computed state to what listeners should see:
// while the OS reports no usable network, "Connecting" would be a lie —
// connection attempts are suspended — so it is reported as NoNetwork.
// Caller must hold serverStateLock.
func (n *notifier) effectiveState(state ClientState) ClientState {
if !n.networkAvailable && state == ClientStateConnecting {
return ClientStateNoNetwork
}
return state
}
// setNetworkAvailable records the OS network availability and re-notifies
// the listener when the flag flips the effective state (Connecting <->
// NoNetwork).
func (n *notifier) setNetworkAvailable(available bool) {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
if n.networkAvailable == available {
n.serverStateLock.Unlock()
return
}
previous := n.effectiveState(n.lastNotification)
n.networkAvailable = available
current := n.effectiveState(n.lastNotification)
n.serverStateLock.Unlock()
if previous != current {
n.notify(current)
}
}
func (n *notifier) setListener(listener Listener) {
n.serverStateLock.Lock()
lastNotification := n.lastNotification
lastNotification := n.effectiveState(n.lastNotification)
numOfPeers := n.lastNumberOfPeers
fqdnAddress := n.lastFqdnAddress
address := n.lastIPAddress
@@ -52,6 +85,9 @@ func (n *notifier) removeListener() {
}
func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
calculatedState := n.calculateState(mgmState, signalState)
@@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
}
n.lastNotification = calculatedState
effective := n.effectiveState(calculatedState)
n.serverStateLock.Unlock()
n.notify(calculatedState)
n.notify(effective)
}
func (n *notifier) clientStart() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = true
n.lastNotification = stateConnecting
n.lastNotification = ClientStateConnecting
effective := n.effectiveState(ClientStateConnecting)
n.serverStateLock.Unlock()
n.notify(stateConnecting)
n.notify(effective)
}
func (n *notifier) clientStop() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = false
n.lastNotification = stateDisconnected
n.lastNotification = ClientStateDisconnected
n.serverStateLock.Unlock()
n.notify(stateDisconnected)
n.notify(ClientStateDisconnected)
}
func (n *notifier) clientTearDown() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = false
n.lastNotification = stateDisconnecting
n.lastNotification = ClientStateDisconnecting
n.serverStateLock.Unlock()
n.notify(stateDisconnecting)
n.notify(ClientStateDisconnecting)
}
func (n *notifier) isServerStateChanged(newState int) bool {
func (n *notifier) isServerStateChanged(newState ClientState) bool {
return n.lastNotification != newState
}
func (n *notifier) notify(state int) {
func (n *notifier) notify(state ClientState) {
n.listenersLock.Lock()
listener := n.listener
n.listenersLock.Unlock()
@@ -109,20 +156,20 @@ func (n *notifier) notify(state int) {
notifyListener(listener, state)
}
func (n *notifier) calculateState(managementConn, signalConn bool) int {
func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
if managementConn && signalConn {
return stateConnected
return ClientStateConnected
}
if !managementConn && !signalConn && !n.currentClientState {
return stateDisconnected
return ClientStateDisconnected
}
if n.lastNotification == stateDisconnecting {
return stateDisconnecting
if n.lastNotification == ClientStateDisconnecting {
return ClientStateDisconnecting
}
return stateConnecting
return ClientStateConnecting
}
func (n *notifier) peerListChanged(numOfPeers int) {
@@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
listener.OnAddressChanged(fqdn, address)
}
func notifyListener(l Listener, state int) {
func notifyListener(l Listener, state ClientState) {
// legacy per-state callbacks; NoNetwork is delivered only via
// OnStateChanged below
switch state {
case stateDisconnected:
case ClientStateDisconnected:
l.OnDisconnected()
case stateConnected:
case ClientStateConnected:
l.OnConnected()
case stateConnecting:
case ClientStateConnecting:
l.OnConnecting()
case stateDisconnecting:
case ClientStateDisconnecting:
l.OnDisconnecting()
}
l.OnStateChanged(state)
}

View File

@@ -0,0 +1,108 @@
package peer
import (
"sync"
"testing"
"time"
)
type recordingListener struct {
mu sync.Mutex
states []ClientState
onState func(ClientState)
}
func (l *recordingListener) OnStateChanged(state ClientState) {
l.mu.Lock()
l.states = append(l.states, state)
hook := l.onState
l.mu.Unlock()
if hook != nil {
hook(state)
}
}
func (l *recordingListener) last() (ClientState, bool) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.states) == 0 {
return 0, false
}
return l.states[len(l.states)-1], true
}
func (l *recordingListener) snapshot() []ClientState {
l.mu.Lock()
defer l.mu.Unlock()
return append([]ClientState(nil), l.states...)
}
func (l *recordingListener) OnConnected() {}
func (l *recordingListener) OnDisconnected() {}
func (l *recordingListener) OnConnecting() {}
func (l *recordingListener) OnDisconnecting() {}
func (l *recordingListener) OnAddressChanged(string, string) {}
func (l *recordingListener) OnPeersListChanged(int) {}
// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first
// transition inside the listener callback and flips availability again from
// another goroutine while it is parked. The second flip must not publish
// ahead of the one in flight, otherwise the listener ends up on a state the
// notifier already superseded.
func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) {
n := newNotifier()
n.currentClientState = true
n.lastNotification = ClientStateConnecting
entered := make(chan struct{})
release := make(chan struct{})
l := &recordingListener{}
l.onState = func(state ClientState) {
if state != ClientStateNoNetwork {
return
}
l.mu.Lock()
l.onState = nil
l.mu.Unlock()
close(entered)
<-release
}
n.listener = l
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
n.setNetworkAvailable(false)
}()
<-entered
flipped := make(chan struct{})
go func() {
defer close(flipped)
n.setNetworkAvailable(true)
}()
select {
case <-flipped:
t.Fatal("the online transition published while the offline one was " +
"still in flight; publication is not serialized")
case <-time.After(200 * time.Millisecond):
}
close(release)
<-flipped
wg.Wait()
got, ok := l.last()
if !ok {
t.Fatal("listener never observed a state")
}
if got != ClientStateConnecting {
t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v",
got, l.snapshot())
}
}

View File

@@ -6,29 +6,32 @@ import (
)
type mocListener struct {
lastState int
lastState ClientState
wg sync.WaitGroup
peersWg sync.WaitGroup
peers int
}
func (l *mocListener) OnConnected() {
l.lastState = stateConnected
l.lastState = ClientStateConnected
l.wg.Done()
}
func (l *mocListener) OnDisconnected() {
l.lastState = stateDisconnected
l.lastState = ClientStateDisconnected
l.wg.Done()
}
func (l *mocListener) OnConnecting() {
l.lastState = stateConnecting
l.lastState = ClientStateConnecting
l.wg.Done()
}
func (l *mocListener) OnDisconnecting() {
l.lastState = stateDisconnecting
l.lastState = ClientStateDisconnecting
l.wg.Done()
}
func (l *mocListener) OnStateChanged(state ClientState) {
}
func (l *mocListener) OnAddressChanged(host, addr string) {
}
@@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) {
type scenario struct {
name string
expected int
expected ClientState
mgmState bool
signalState bool
}
scenarios := []scenario{
{"connected", stateConnected, true, true},
{"mgm down", stateConnecting, false, true},
{"signal down", stateConnecting, true, false},
{"disconnected", stateDisconnected, false, false},
{"connected", ClientStateConnected, true, true},
{"mgm down", ClientStateConnecting, false, true},
{"signal down", ClientStateConnecting, true, false},
{"disconnected", ClientStateDisconnected, false, false},
}
for _, tt := range scenarios {
@@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) {
listener.setPeersWaiter()
n := newNotifier()
n.lastNotification = stateConnecting
n.lastNotification = ClientStateConnecting
n.setListener(listener)
listener.wait()
listener.waitPeers()
@@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
listener.setWaiter()
listener.setPeersWaiter()
n := newNotifier()
n.lastNotification = stateConnecting
n.lastNotification = ClientStateConnecting
n.setListener(listener)
// setListener replays cached state on a goroutine; wait for both the state
// and peers callbacks to finish so we don't race on listener.peers.

View File

@@ -1211,6 +1211,12 @@ func (d *Status) ClientTeardown() {
d.notifyStateChange()
}
// SetNetworkAvailable records the OS-reported network availability; while
// unavailable, listeners see NoNetwork instead of Connecting.
func (d *Status) SetNetworkAvailable(available bool) {
d.notifier.setNetworkAvailable(available)
}
// SetConnectionListener set a listener to the notifier
func (d *Status) SetConnectionListener(listener Listener) {
d.notifier.setListener(listener)

View File

@@ -0,0 +1,130 @@
package profilemanager
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/netbirdio/netbird/util"
)
const prefsFileSuffix = ".prefs.json"
var prefsMu sync.Mutex
// Prefs is a namespaced per-profile preference store backed by a single JSON
// file next to the profile config; it is deleted together with the profile.
type Prefs struct {
path string
}
// ProfilePrefs returns the preference store of the profile identified by id.
func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) {
if !IsValidProfileFilenameStem(id) {
return nil, fmt.Errorf("invalid profile ID: %q", id)
}
if id == defaultProfileName {
return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil
}
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("get config directory for user %s: %w", username, err)
}
return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil
}
// Get unmarshals the namespace section into v and reports whether it exists.
func (p *Prefs) Get(namespace string, v any) (bool, error) {
if namespace == "" {
return false, fmt.Errorf("empty prefs namespace")
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return false, err
}
raw, ok := sections[namespace]
if !ok {
return false, nil
}
if err := json.Unmarshal(raw, v); err != nil {
return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err)
}
return true, nil
}
// Put stores v as the namespace section, replacing any previous value.
func (p *Prefs) Put(namespace string, v any) error {
if namespace == "" {
return fmt.Errorf("empty prefs namespace")
}
raw, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("encode prefs namespace %q: %w", namespace, err)
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return err
}
sections[namespace] = raw
return writePrefsFile(p.path, sections)
}
// Remove deletes the namespace section; a missing one is not an error.
func (p *Prefs) Remove(namespace string) error {
if namespace == "" {
return fmt.Errorf("empty prefs namespace")
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return err
}
if _, ok := sections[namespace]; !ok {
return nil
}
delete(sections, namespace)
return writePrefsFile(p.path, sections)
}
func removePrefsFile(path string) error {
prefsMu.Lock()
defer prefsMu.Unlock()
return os.Remove(path)
}
func readPrefsFile(path string) (map[string]json.RawMessage, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
if err != nil {
return nil, fmt.Errorf("read prefs: %w", err)
}
sections := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &sections); err != nil {
return nil, fmt.Errorf("decode prefs: %w", err)
}
return sections, nil
}
func writePrefsFile(path string, sections map[string]json.RawMessage) error {
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil {
return fmt.Errorf("write prefs: %w", err)
}
return nil
}

View File

@@ -0,0 +1,138 @@
package profilemanager
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testPrefsSection struct {
Mode uint8 `json:"mode"`
Dest string `json:"dest"`
}
func TestProfilePrefs_RoundTrip(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"}))
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got)
var other map[string]int
found, err = prefs.Get("other", &other)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, map[string]int{"n": 1}, other)
})
}
func TestProfilePrefs_GetMissingNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.False(t, found)
})
}
func TestProfilePrefs_RemoveNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
require.NoError(t, prefs.Remove("filedrop"))
require.NoError(t, prefs.Remove("missing"))
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.False(t, found)
var other map[string]int
found, err = prefs.Get("other", &other)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, map[string]int{"n": 1}, other)
})
}
func TestProfilePrefs_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.ProfilePrefs("../escape", username)
assert.Error(t, err)
})
}
func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
_, err = prefs.Get("", &testPrefsSection{})
assert.Error(t, err)
assert.Error(t, prefs.Put("", testPrefsSection{}))
assert.Error(t, prefs.Remove(""))
})
}
func TestProfilePrefs_DefaultProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
prefs, err := sm.ProfilePrefs(defaultProfileName, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix)
_, err = os.Stat(expected)
require.NoError(t, err)
})
}
func TestRemoveProfile_DeletesPrefsFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2}))
configDir, err := sm.getConfigDir(username)
require.NoError(t, err)
prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix)
_, err = os.Stat(prefsPath)
require.NoError(t, err)
require.NoError(t, sm.RemoveProfile(created.ID, username))
_, err = os.Stat(prefsPath)
assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed")
})
}

View File

@@ -420,6 +420,11 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error {
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
}
prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix)
if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) {
log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err)
}
return nil
}

View File

@@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer {
// This will run by the original service process
func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) {
resultHandler := NewResultHandler(u.tempDir)
if err := resultHandler.ClearStaleResult(); err != nil {
log.Warnf("clear stale installer result: %v", err)
}
defer func() {
if err != nil {

View File

@@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string {
return ""
}
// ClearStaleResult removes a result file left over from a previous installation
// attempt so result watchers cannot read an outdated outcome for the current attempt.
func (rh *ResultHandler) ClearStaleResult() error {
return rh.cleanup()
}
func (rh *ResultHandler) WriteSuccess() error {
result := Result{
Success: true,

View File

@@ -22,6 +22,8 @@ import (
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
@@ -36,11 +38,6 @@ const (
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile
type ConnectionListener interface {
peer.Listener
}
// RouteListener export internal RouteListener for mobile
type NetworkChangeListener interface {
listener.NetworkChangeListener
@@ -87,6 +84,12 @@ type Client struct {
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
// netState outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run injects it into each new ConnectClient, which
// distributes it to every reconnection loop.
netState *netstate.State
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
sweeper *netsweep.Sweeper
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
@@ -109,6 +112,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
netState: netstate.New(),
sweeper: netsweep.New(),
}
}
@@ -184,7 +189,8 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.onHostDnsFn = func([]string) {}
cfg.WgIface = interfaceName
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.setState(cfg, connectClient)
// Persist the latest sync response so DebugBundle can include the network
// map. On iOS this is backed by disk to keep it out of the constrained
@@ -193,6 +199,25 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
}
// SetNetworkAvailable feeds OS-reported network availability into the client
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
// suspend their attempts and the connection listener reports NoNetwork
// instead of Connecting; when availability returns, the loops resume
// immediately with a fresh backoff.
func (c *Client) SetNetworkAvailable(available bool) {
c.netState.Set(available)
c.recorder.SetNetworkAvailable(available)
}
// NotifyNetworkChange marks the management, signal and relay connections
// stale after the OS switched networks and schedules a sweep that cuts
// whatever has not redialed on the new network by then. The engine and the
// TUN device stay untouched.
func (c *Client) NotifyNetworkChange() {
c.sweeper.MarkNetworkChange()
log.Infof("network change: connections marked stale")
}
// Stop the internal client and free the resources
func (c *Client) Stop() {
c.ctxCancelLock.Lock()
@@ -331,7 +356,11 @@ func (c *Client) GetStatusDetails() *StatusDetails {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
c.recorder.SetConnectionListener(listener)
if listener == nil {
c.recorder.RemoveConnectionListener()
return
}
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
}
// RemoveConnectionListener remove connection listener

View File

@@ -0,0 +1,43 @@
//go:build ios
package NetBirdSDK
import (
"github.com/netbirdio/netbird/client/internal/peer"
)
// Client state values, re-exported as basic constants so gomobile emits them
// into the generated bindings. They mirror peer.ClientState*: append-only,
// never reorder.
const (
ClientStateDisconnected = int(peer.ClientStateDisconnected)
ClientStateConnected = int(peer.ClientStateConnected)
ClientStateConnecting = int(peer.ClientStateConnecting)
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
)
// ConnectionListener export internal Listener for mobile.
//
// It intentionally lacks OnStateChanged for now: adding a method to a gomobile
// interface breaks every Swift implementation, so the iOS app keeps building
// against the legacy per-state callbacks. A follow-up will extend it together
// with the app.
type ConnectionListener interface {
OnConnected()
OnDisconnected()
OnConnecting()
OnDisconnecting()
OnAddressChanged(string, string)
OnPeersListChanged(int)
}
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
// peer.Listener.
type connectionListenerAdapter struct {
ConnectionListener
}
// OnStateChanged is dropped on iOS until the app adopts the state callback;
// the legacy per-state callbacks continue to fire.
func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {}

View File

@@ -323,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
const authInfoRequestTimeout = 30 * time.Second
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth)
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "")
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}

110
client/netstate/netstate.go Normal file
View File

@@ -0,0 +1,110 @@
// Package netstate tracks OS-reported network availability for the client.
//
// A State instance is owned by the platform integration (e.g. the Android or
// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and
// is injected into the connection retry loops (management, signal, relay,
// peer guards and the top-level connect loop), which consult it to avoid
// burning CPU and battery on reconnect attempts while the device has no
// network at all (e.g. airplane mode), and to reset their backoff as soon as
// the network returns.
//
// Consumers hold a *State that may be nil — every non-mobile platform leaves
// it unset. The read methods are safe on a nil receiver: they report online
// and never block, so consumers behave as if this package did not exist.
package netstate
import (
"context"
"sync"
log "github.com/sirupsen/logrus"
)
// State holds the OS-reported network availability. The zero value is not
// usable; create instances with New.
type State struct {
mu sync.Mutex
online bool
changed chan struct{}
}
// New creates a State that starts online. Platforms without network tracking
// pass a nil *State instead: the read methods treat nil as always online and
// never block, so consumers need no nil guards.
func New() *State {
return &State{
online: true,
changed: make(chan struct{}),
}
}
// Set records whether the OS reports any usable network. Transitions wake up
// all Wait callers immediately. Unlike the read methods, Set is not nil-safe:
// it is only for the platform owner that created the State with New.
func (s *State) Set(online bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.online == online {
return
}
s.online = online
close(s.changed)
s.changed = make(chan struct{})
log.Infof("OS network availability changed: online=%t", online)
}
// IsOnline reports whether the OS reports at least one usable network. On a
// nil receiver — no State injected — it reports online.
func (s *State) IsOnline() bool {
if s == nil {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
return s.online
}
// Changed returns a channel closed on the next availability transition, for
// callers that already own a select loop and cannot block in Wait. Re-read it
// after every fire: each transition installs a fresh channel. On a nil
// receiver — no State injected — it returns nil, which blocks forever in a
// select, so the caller simply never observes a transition.
func (s *State) Changed() <-chan struct{} {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.changed
}
// Wait blocks while the network is offline. It reports whether it had to
// wait, so callers can reset their backoff after an outage. It returns early
// with the context error when ctx is done. On a nil receiver — no State
// injected — it returns immediately.
func (s *State) Wait(ctx context.Context) (bool, error) {
if s == nil {
return false, nil
}
waited := false
for {
s.mu.Lock()
if s.online {
s.mu.Unlock()
return waited, nil
}
ch := s.changed
s.mu.Unlock()
if !waited {
waited = true
log.Debugf("network is offline, pausing connection attempts")
}
select {
case <-ctx.Done():
return waited, ctx.Err()
case <-ch:
}
}
}

View File

@@ -0,0 +1,170 @@
package netstate
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewStateIsOnline(t *testing.T) {
assert.True(t, New().IsOnline(), "a fresh State should start online")
}
func TestSetTogglesOnlineState(t *testing.T) {
s := New()
s.Set(false)
assert.False(t, s.IsOnline(), "state should be offline after Set(false)")
s.Set(true)
assert.True(t, s.IsOnline(), "state should be online after Set(true)")
}
func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) {
s := New()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waited, err := s.Wait(ctx)
require.NoError(t, err)
assert.False(t, waited, "Wait should not block when the network is online")
}
func TestWaitBlocksUntilOnline(t *testing.T) {
s := New()
s.Set(false)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := make(chan bool, 1)
go func() {
waited, err := s.Wait(ctx)
if err != nil {
result <- false
return
}
result <- waited
}()
// Verify Wait is actually blocking while offline
select {
case <-result:
t.Fatal("Wait should block while the network is offline")
case <-time.After(100 * time.Millisecond):
}
s.Set(true)
select {
case waited := <-result:
assert.True(t, waited, "Wait should report that it had to wait for the network")
case <-time.After(2 * time.Second):
t.Fatal("Wait should return promptly after the network becomes available")
}
}
func TestWaitReturnsOnContextCancel(t *testing.T) {
s := New()
s.Set(false)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
_, err := s.Wait(ctx)
result <- err
}()
cancel()
select {
case err := <-result:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatal("Wait should return promptly after context cancellation")
}
}
func TestWaitWakesAllWaiters(t *testing.T) {
s := New()
s.Set(false)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
const waiters = 10
var wg sync.WaitGroup
results := make(chan bool, waiters)
for i := 0; i < waiters; i++ {
wg.Add(1)
go func() {
defer wg.Done()
waited, err := s.Wait(ctx)
if err != nil {
results <- false
return
}
results <- waited
}()
}
time.Sleep(100 * time.Millisecond)
s.Set(true)
wg.Wait()
close(results)
count := 0
for waited := range results {
assert.True(t, waited, "every waiter should report that it waited")
count++
}
assert.Equal(t, waiters, count, "all waiters should have returned")
}
func TestNilStateReadsAreNoops(t *testing.T) {
var s *State
assert.True(t, s.IsOnline(), "nil State should report online")
waited, err := s.Wait(context.Background())
require.NoError(t, err)
assert.False(t, waited, "nil State's Wait should not block")
}
func TestConcurrentSetAndWait(t *testing.T) {
s := New()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
s.Set(j%2 == 0)
s.IsOnline()
}
}()
}
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
if _, err := s.Wait(ctx); err != nil {
return
}
}
}()
}
wg.Wait()
}

267
client/netsweep/netsweep.go Normal file
View File

@@ -0,0 +1,267 @@
// Package netsweep cuts network-bound activity when the OS switches networks:
// a sweep closes the registered connections and aborts the in-flight dials, so
// their owners redial immediately instead of waiting for the old sockets to
// time out.
//
// A nil *Sweeper disables everything: all methods are nil-safe no-ops.
package netsweep
import (
"context"
"errors"
"net"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
)
// DefaultSweepDelay absorbs network flapping while the OS settles on a
// default network before the stale registrations are cut.
const DefaultSweepDelay = 500 * time.Millisecond
const recentMarkWindow = 3 * time.Second
// Config customizes a Sweeper. The zero value applies the defaults.
type Config struct {
// SweepDelay overrides DefaultSweepDelay when positive.
SweepDelay time.Duration
}
// ErrSwept reports that a dial finished after a network change swept its
// registration. The connection is already closed; the caller must treat it
// as a failed dial and redial on the new network.
var ErrSwept = errors.New("netsweep: connection swept by network change")
// sweepID identifies one registration in a sweeper. Connections and dials
// draw from the same counter, so an id is unique across both registries.
type sweepID uint64
type connEntry struct {
conn net.Conn
gen uint64
}
// Dial tracks one dial from start to connection registration. It hands the
// dialed connection to the sweeper atomically, so a sweep can never fall
// between the dial finishing and the connection being registered.
type Dial struct {
sweeper *Sweeper
ctx context.Context
cancel context.CancelFunc
id sweepID
done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu
gen uint64
}
// Ctx returns the dial's context. A sweep cancels it, so a dial started on the
// old network aborts instead of waiting out its handshake timeout.
func (d *Dial) Ctx() context.Context {
return d.ctx
}
// Release ends the dial's registration and cancels its context. It is
// idempotent and safe after WrapConn, so callers can defer it.
func (d *Dial) Release() {
s := d.sweeper
if s == nil {
return
}
s.mu.Lock()
d.done = true
delete(s.dials, d.id)
s.mu.Unlock()
d.cancel()
}
// sweptConn deregisters itself from the sweeper when closed.
type sweptConn struct {
net.Conn
sweeper *Sweeper
id sweepID
}
func (c *sweptConn) Close() error {
c.sweeper.deregister(c.id)
return c.Conn.Close()
}
// Sweeper registers live connections and in-flight dials so the
// network-change sweep can cut everything registered before the change.
type Sweeper struct {
mu sync.Mutex
conns map[sweepID]connEntry
dials map[sweepID]*Dial
nextID sweepID
gen uint64
timer *time.Timer
sweepDelay time.Duration
lastMark time.Time
}
// New creates an empty sweeper with the default configuration.
func New() *Sweeper {
return NewWithConfig(Config{})
}
// NewWithConfig creates an empty sweeper customized by cfg.
func NewWithConfig(cfg Config) *Sweeper {
delay := cfg.SweepDelay
if delay <= 0 {
delay = DefaultSweepDelay
}
return &Sweeper{
conns: make(map[sweepID]connEntry),
dials: make(map[sweepID]*Dial),
sweepDelay: delay,
}
}
// StartDial registers an in-flight dial. Dial with Ctx, hand the result to
// WrapConn, and Release the dial when the attempt is over, typically deferred.
func (s *Sweeper) StartDial(ctx context.Context) *Dial {
if s == nil {
return &Dial{ctx: ctx}
}
ctx, cancel := context.WithCancel(ctx)
d := &Dial{sweeper: s, ctx: ctx, cancel: cancel}
s.mu.Lock()
d.id = s.nextID
s.nextID++
d.gen = s.gen
s.dials[d.id] = d
s.mu.Unlock()
return d
}
// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial,
// the connection belongs to the old network: it is closed and ErrSwept is
// returned. Otherwise conn is registered against the next sweep and returned
// wrapped, deregistering itself on Close. Call it once, before Release.
func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) {
s := d.sweeper
if s == nil {
return conn, nil
}
s.mu.Lock()
if d.done {
s.mu.Unlock()
if err := conn.Close(); err != nil {
log.Debugf("swept dial close error: %v", err)
}
return nil, ErrSwept
}
d.done = true
delete(s.dials, d.id)
id := s.nextID
s.nextID++
// The conn inherits the dial's generation: the socket was bound to the
// network that was default when the dial started, not when it finished.
s.conns[id] = connEntry{conn: conn, gen: d.gen}
s.mu.Unlock()
return &sweptConn{Conn: conn, sweeper: s, id: id}, nil
}
// MarkNetworkChange records that the OS switched networks: everything
// registered so far becomes stale, and a sweep is (re)scheduled after the
// configured delay to cut whatever is still stale by then. Owners that
// redialed in the meantime hold fresh-generation registrations and survive,
// so no cancellation is needed around the sweep.
func (s *Sweeper) MarkNetworkChange() {
if s == nil {
return
}
s.mu.Lock()
s.gen++
cutoff := s.gen
s.lastMark = time.Now()
if s.timer != nil {
s.timer.Stop()
}
s.timer = time.AfterFunc(s.sweepDelay, func() {
n := s.sweep(cutoff)
log.Infof("network change sweep: closed %d stale connections", n)
})
s.mu.Unlock()
}
// QuickRetryBackoff wraps bo so that after each Reset the first retry comes
// quickly when the disconnect followed a recent network change and the
// network is online. Any other failure keeps bo's spread, so the clients of
// a restarted server still scatter their reconnects. A nil sweeper returns
// bo unchanged.
func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff {
if s == nil {
return bo
}
return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx)
}
func (s *Sweeper) markedRecently() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow
}
// sweep closes the registered connections and aborts the in-flight dials
// older than cutoff, and returns how many connections it closed. A dial
// whose connection was not yet handed to WrapConn is marked, so the late
// WrapConn closes it instead of registering it.
func (s *Sweeper) sweep(cutoff uint64) int {
if s == nil {
return 0
}
s.mu.Lock()
var conns []net.Conn
for id, e := range s.conns {
if e.gen < cutoff {
delete(s.conns, id)
conns = append(conns, e.conn)
}
}
var dials []*Dial
for id, d := range s.dials {
if d.gen < cutoff {
d.done = true
delete(s.dials, id)
dials = append(dials, d)
}
}
s.mu.Unlock()
if len(dials) > 0 {
log.Debugf("aborting %d in-flight dials", len(dials))
for _, d := range dials {
d.cancel()
}
}
for _, conn := range conns {
log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr())
if err := conn.Close(); err != nil {
log.Debugf("swept connection close error: %v", err)
}
}
return len(conns)
}
func (s *Sweeper) deregister(id sweepID) {
s.mu.Lock()
delete(s.conns, id)
s.mu.Unlock()
}

View File

@@ -0,0 +1,241 @@
package netsweep
import (
"context"
"math"
"net"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSweepClosesRegisteredConns(t *testing.T) {
sweeper := New()
c1 := wrap(t, sweeper, connPair(t))
c2 := wrap(t, sweeper, connPair(t))
assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed")
// The wrappers must report closed now.
buf := make([]byte, 1)
_, err := c1.Read(buf)
assert.Error(t, err, "first connection should be unusable after the sweep")
_, err = c2.Read(buf)
assert.Error(t, err, "second connection should be unusable after the sweep")
assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing")
}
func TestCloseDeregisters(t *testing.T) {
sweeper := New()
conn := wrap(t, sweeper, connPair(t))
require.NoError(t, conn.Close())
assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry")
}
func TestCloseIsIdempotent(t *testing.T) {
sweeper := New()
conn := wrap(t, sweeper, connPair(t))
require.NoError(t, conn.Close())
assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic")
}
func TestSweepOnlyAffectsOlderConns(t *testing.T) {
sweeper := New()
_ = wrap(t, sweeper, connPair(t))
assert.Equal(t, 1, sweeper.sweepAll())
// A connection dialed after the sweep must survive until the next one.
_ = wrap(t, sweeper, connPair(t))
assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep")
}
func TestSweepAbortsInFlightDials(t *testing.T) {
sweeper := New()
dial := sweeper.StartDial(context.Background())
defer dial.Release()
sweeper.sweepAll()
assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context")
}
func TestReleasedDialIsNotAborted(t *testing.T) {
sweeper := New()
// Simulate a dial that finished before the sweep.
released := sweeper.StartDial(context.Background())
released.Release()
// A dial still in flight during the sweep.
pending := sweeper.StartDial(context.Background())
defer pending.Release()
sweeper.sweepAll()
assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted")
}
func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) {
sweeper := New()
dial := sweeper.StartDial(context.Background())
defer dial.Release()
// The dial succeeds on the old network, then the sweep lands before the
// connection is handed over.
conn := connPair(t)
assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet")
wrapped, err := dial.WrapConn(conn)
require.ErrorIs(t, err, ErrSwept)
require.Nil(t, wrapped)
buf := make([]byte, 1)
_, err = conn.Read(buf)
assert.Error(t, err, "the old-network connection must be closed, not leaked")
assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep")
}
func TestMarkNetworkChangeSparesFreshConns(t *testing.T) {
sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
stale := wrap(t, sweeper, connPair(t))
sweeper.MarkNetworkChange()
_ = wrap(t, sweeper, connPair(t))
_ = stale.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 1)
_, err := stale.Read(buf)
require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep")
assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep")
}
func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) {
sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
stale := sweeper.StartDial(context.Background())
defer stale.Release()
sweeper.MarkNetworkChange()
fresh := sweeper.StartDial(context.Background())
defer fresh.Release()
assert.Eventually(t, func() bool {
return stale.Ctx().Err() != nil
}, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep")
assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted")
}
func TestConnInheritsDialGeneration(t *testing.T) {
sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
// The dial starts before the network change but completes after it: the
// socket is bound to the old network, so the sweep must still cut it.
dial := sweeper.StartDial(context.Background())
defer dial.Release()
sweeper.MarkNetworkChange()
wrapped, err := dial.WrapConn(connPair(t))
require.NoError(t, err)
_ = wrapped.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 1)
_, err = wrapped.Read(buf)
require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept")
}
func TestRepeatedMarksCoalesce(t *testing.T) {
sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
first := wrap(t, sweeper, connPair(t))
sweeper.MarkNetworkChange()
second := wrap(t, sweeper, connPair(t))
sweeper.MarkNetworkChange()
_ = wrap(t, sweeper, connPair(t))
buf := make([]byte, 1)
for _, conn := range []net.Conn{first, second} {
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
_, err := conn.Read(buf)
require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep")
}
assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain")
}
func TestNilSweeperIsNoop(t *testing.T) {
var sweeper *Sweeper
conn := connPair(t)
dial := sweeper.StartDial(context.Background())
defer dial.Release()
wrapped, err := dial.WrapConn(conn)
require.NoError(t, err)
assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged")
assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context")
assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing")
}
// wrap registers conn with the sweeper through a completed dial.
func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn {
t.Helper()
dial := sweeper.StartDial(context.Background())
defer dial.Release()
wrapped, err := dial.WrapConn(conn)
require.NoError(t, err)
return wrapped
}
// connPair dials a loopback TCP connection and keeps the accepted peer open
// until the test ends: a peer that closed early would make the connection
// unreadable on its own, so a read error after the sweep would prove nothing.
func connPair(t *testing.T) net.Conn {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
if err := l.Close(); err != nil {
t.Logf("listener close error: %v", err)
}
})
accepted := make(chan net.Conn, 1)
go func() {
conn, err := l.Accept()
if err != nil {
close(accepted)
return
}
accepted <- conn
}()
conn, err := net.Dial("tcp", l.Addr().String())
require.NoError(t, err)
peer, ok := <-accepted
require.True(t, ok, "listener must accept the dialed connection")
t.Cleanup(func() {
if err := peer.Close(); err != nil {
t.Logf("peer close error: %v", err)
}
})
return conn
}
// sweepAll cuts every registration regardless of generation.
func (s *Sweeper) sweepAll() int {
return s.sweep(math.MaxUint64)
}

View File

@@ -0,0 +1,39 @@
package netsweep
import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/netbirdio/netbird/client/netstate"
)
const quickRetryDelay = 200 * time.Millisecond
type quickRetryBackoff struct {
backoff.BackOff
sweeper *Sweeper
netState *netstate.State
used bool
}
func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff {
return &quickRetryBackoff{
BackOff: bo,
sweeper: sweeper,
netState: netState,
}
}
func (b *quickRetryBackoff) NextBackOff() time.Duration {
if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() {
b.used = true
return quickRetryDelay
}
return b.BackOff.NextBackOff()
}
func (b *quickRetryBackoff) Reset() {
b.used = false
b.BackOff.Reset()
}

View File

@@ -0,0 +1,58 @@
package netsweep
import (
"context"
"testing"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/stretchr/testify/assert"
)
func TestQuickRetryAfterRecentMark(t *testing.T) {
sweeper := New()
sweeper.MarkNetworkChange()
slow := backoff.NewConstantBackOff(5 * time.Second)
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick")
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff")
bo.Reset()
assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry")
}
func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) {
sweeper := New()
slow := backoff.NewConstantBackOff(5 * time.Second)
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides")
sweeper.mu.Lock()
sweeper.lastMark = time.Now().Add(-recentMarkWindow)
sweeper.mu.Unlock()
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry")
}
func TestQuickRetryNilSweeperPassthrough(t *testing.T) {
var sweeper *Sweeper
slow := backoff.NewConstantBackOff(5 * time.Second)
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged")
}
func TestQuickRetryHonorsContext(t *testing.T) {
sweeper := New()
sweeper.MarkNetworkChange()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil)
assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop")
}

View File

@@ -313,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er
// dialSSH establishes an SSH connection without JWT authentication
func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) {
if config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
client, err := nbssh.Handshake(ctx, conn, addr, config)
if err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("connection close after handshake failure: %v", closeErr)
}
return nil, fmt.Errorf("ssh handshake: %w", err)
return nil, err
}
client := ssh.NewClient(clientConn, chans, reqs)
return &Client{
client: client,
}, nil

View File

@@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error {
@@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
return fmt.Errorf("get terminal size: %w", err)
}
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
// Ctrl+C
ssh.VINTR: 3,
// Ctrl+\
ssh.VQUIT: 28,
// Backspace
ssh.VERASE: 127,
// Ctrl+U
ssh.VKILL: 21,
// Ctrl+D
ssh.VEOF: 4,
ssh.VEOL: 0,
ssh.VEOL2: 0,
// Ctrl+Q
ssh.VSTART: 17,
// Ctrl+S
ssh.VSTOP: 19,
// Ctrl+Z
ssh.VSUSP: 26,
// Ctrl+O
ssh.VDISCARD: 15,
// Ctrl+R
ssh.VREPRINT: 18,
// Ctrl+W
ssh.VWERASE: 23,
// Ctrl+V
ssh.VLNEXT: 22,
}
modes := nbssh.DefaultTerminalModes
terminal := os.Getenv("TERM")
if terminal == "" {

View File

@@ -10,6 +10,8 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
const (
@@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
w, h := c.getWindowsConsoleSize()
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
ssh.ICRNL: 1,
ssh.OPOST: 1,
ssh.ONLCR: 1,
ssh.ISIG: 1,
ssh.ICANON: 1,
ssh.VINTR: 3, // Ctrl+C
ssh.VQUIT: 28, // Ctrl+\
ssh.VERASE: 127, // Backspace
ssh.VKILL: 21, // Ctrl+U
ssh.VEOF: 4, // Ctrl+D
ssh.VEOL: 0,
ssh.VEOL2: 0,
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
ssh.VREPRINT: 18, // Ctrl+R
ssh.ICRNL: 1,
ssh.OPOST: 1,
ssh.ONLCR: 1,
ssh.ISIG: 1,
ssh.ICANON: 1,
}
for mode, value := range nbssh.DefaultTerminalModes {
modes[mode] = value
}
if err := session.RequestPty("xterm-256color", h, w, modes); err != nil {

View File

@@ -35,6 +35,19 @@ type HostKeyVerifier interface {
VerifySSHHostKey(peerAddress string, key []byte) error
}
// PeerKeyLookup returns the stored SSH host key for a peer address.
type PeerKeyLookup func(peerAddress string) ([]byte, bool)
// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key
// and comparing it against the presented key.
func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error {
storedKey, found := l(peerAddress)
if !found {
return ErrPeerNotFound
}
return VerifyHostKey(storedKey, presentedKey, peerAddress)
}
// DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon
type DaemonHostKeyVerifier struct {
client proto.DaemonServiceClient

45
client/ssh/handshake.go Normal file
View File

@@ -0,0 +1,45 @@
package ssh
import (
"context"
"fmt"
"io"
"net"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
// Handshake runs the SSH client handshake on an already dialed conn and
// returns the resulting client. Dialing bounds only the TCP establishment;
// without a deadline on the socket a peer that accepts and then goes silent
// blocks the handshake forever, so the context deadline is applied to conn
// for the duration of the handshake. conn is closed on any error.
func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
closeHandshake(conn, "conn after deadline error")
return nil, fmt.Errorf("set handshake deadline: %w", err)
}
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
closeHandshake(conn, "conn after handshake error")
return nil, fmt.Errorf("ssh handshake: %w", err)
}
if err := conn.SetDeadline(time.Time{}); err != nil {
closeHandshake(sshConn, "ssh conn after deadline clear error")
return nil, fmt.Errorf("clear handshake deadline: %w", err)
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
func closeHandshake(c io.Closer, label string) {
if err := c.Close(); err != nil {
log.Debugf("ssh: close %s: %v", label, err)
}
}

View File

@@ -610,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string)
return nil, fmt.Errorf("connect to server: %w", err)
}
clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("SSH handshake: %w", err)
}
handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout)
defer cancel()
return cryptossh.NewClient(clientConn, chans, reqs), nil
return nbssh.Handshake(handshakeCtx, conn, addr, config)
}
func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error {

84
client/ssh/session.go Normal file
View File

@@ -0,0 +1,84 @@
package ssh
import (
"fmt"
"io"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
// DefaultTerminalModes are the PTY modes used by the interactive terminal clients.
var DefaultTerminalModes = ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
ssh.VINTR: 3, // Ctrl+C
ssh.VQUIT: 28, // Ctrl+\
ssh.VERASE: 127, // Backspace
ssh.VKILL: 21, // Ctrl+U
ssh.VEOF: 4, // Ctrl+D
ssh.VEOL: 0,
ssh.VEOL2: 0,
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VREPRINT: 18, // Ctrl+R
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
}
// PTYSession is an interactive shell session with a PTY and its I/O pipes.
type PTYSession struct {
Session *ssh.Session
Stdin io.WriteCloser
Stdout io.Reader
Stderr io.Reader
}
// StartPTYSession opens a session on the client, requests an xterm-256color PTY
// with the default terminal modes, wires up the I/O pipes and starts a shell.
// The session is closed on any error.
func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) {
session, err := client.NewSession()
if err != nil {
return nil, fmt.Errorf("new session: %w", err)
}
pty, err := setupPTYSession(session, cols, rows)
if err != nil {
if closeErr := session.Close(); closeErr != nil {
log.Debugf("ssh: session close after setup error: %v", closeErr)
}
return nil, err
}
return pty, nil
}
// setupPTYSession requests the PTY, opens the pipes and starts the shell on an
// already created session.
func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) {
if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil {
return nil, fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
return nil, fmt.Errorf("stderr pipe: %w", err)
}
if err := session.Shell(); err != nil {
return nil, fmt.Errorf("start shell: %w", err)
}
return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil
}

View File

@@ -80,13 +80,12 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer
return fmt.Errorf("dial %s: %w", addr, err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
sshClient, err := nbssh.Handshake(ctx, conn, addr, config)
if err != nil {
closeWithLog(conn, "connection after handshake error")
return fmt.Errorf("SSH handshake: %w", err)
return err
}
c.sshClient = ssh.NewClient(sshConn, chans, reqs)
c.sshClient = sshClient
logrus.Infof("SSH: Connected to %s", addr)
return nil
@@ -119,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) {
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
// StartSession starts an SSH session with PTY
// StartSession starts an SSH session with PTY. It holds the client lock for
// the whole startup so Close cannot tear the client down mid-setup and the
// new session cannot be installed into an already closed client.
func (c *Client) StartSession(cols, rows int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.sshClient == nil {
return fmt.Errorf("SSH client not connected")
}
session, err := c.sshClient.NewSession()
pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows)
if err != nil {
return fmt.Errorf("create session: %w", err)
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.session = session
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
ssh.VINTR: 3,
ssh.VQUIT: 28,
ssh.VERASE: 127,
}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
closeWithLog(session, "session after PTY error")
return fmt.Errorf("PTY request: %w", err)
}
c.stdin, err = session.StdinPipe()
if err != nil {
closeWithLog(session, "session after stdin error")
return fmt.Errorf("get stdin: %w", err)
}
c.stdout, err = session.StdoutPipe()
if err != nil {
closeWithLog(session, "session after stdout error")
return fmt.Errorf("get stdout: %w", err)
}
c.stderr, err = session.StderrPipe()
if err != nil {
closeWithLog(session, "session after stderr error")
return fmt.Errorf("get stderr: %w", err)
}
if err := session.Shell(); err != nil {
closeWithLog(session, "session after shell error")
return fmt.Errorf("start shell: %w", err)
}
c.session = pty.Session
c.stdin = pty.Stdin
c.stdout = pty.Stdout
c.stderr = pty.Stderr
logrus.Info("SSH: Session started with PTY")
return nil

View File

@@ -281,6 +281,8 @@ read_enable_crowdsec() {
echo "CrowdSec checks client IPs against a community threat intelligence database" > /dev/stderr
echo "and blocks known malicious sources before they reach your services." > /dev/stderr
echo "A local CrowdSec LAPI container will be added to your deployment." > /dev/stderr
echo "It also enables the AppSec (WAF) endpoint, so services can inspect HTTP" > /dev/stderr
echo "requests for exploits. Both stay off per service until you enable them." > /dev/stderr
echo -n "Enable CrowdSec? [y/N]: " > /dev/stderr
read -r CHOICE < /dev/tty
@@ -554,7 +556,8 @@ generate_configuration_files() {
# TCP ServersTransport for PROXY protocol v2 to the proxy backend
render_traefik_dynamic > traefik-dynamic.yaml
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
mkdir -p crowdsec
mkdir -p crowdsec/acquis.d
render_crowdsec_appsec_acquis > crowdsec/acquis.d/appsec.yaml
fi
fi
;;
@@ -589,6 +592,23 @@ generate_configuration_files() {
return 0
}
# The AppSec (WAF) listener only exists if an appsec acquisition datasource is
# configured. One datasource is one listener carrying one merged rule set: the
# protocol has no rule-set selector, so per-service rule variation would need
# either a second datasource on another port or pre_eval hooks filtering on
# req.Host.
render_crowdsec_appsec_acquis() {
cat <<EOF
source: appsec
listen_addr: 0.0.0.0:7422
appsec_configs:
- crowdsecurity/appsec-default
labels:
type: appsec
EOF
return 0
}
start_services_and_show_instructions() {
# For built-in Traefik, start containers immediately
# For NPM, start containers first (NPM needs services running to create proxy)
@@ -805,7 +825,11 @@ render_docker_compose_traefik_builtin() {
restart: unless-stopped
networks: [netbird]
environment:
COLLECTIONS: crowdsecurity/linux
# appsec-generic-rules is required alongside appsec-virtual-patching:
# the appsec-default config references crowdsecurity/generic-* and
# crowdsecurity/experimental-*, which only that collection provides, and
# the engine exits at startup if they are missing.
COLLECTIONS: crowdsecurity/linux crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
volumes:
- ./crowdsec:/etc/crowdsec
- crowdsec_db:/var/lib/crowdsec/data
@@ -1071,6 +1095,11 @@ EOF
cat <<EOF
NB_PROXY_CROWDSEC_API_URL=http://crowdsec:8080
NB_PROXY_CROWDSEC_API_KEY=$CROWDSEC_BOUNCER_KEY
# AppSec (WAF) request inspection. Separate endpoint from the LAPI above and
# validated with the same bouncer key. Setting it makes the proxy advertise the
# AppSec capability, which is what lets a service select appsec_mode; nothing is
# inspected until a service opts in.
NB_PROXY_CROWDSEC_APPSEC_URL=http://crowdsec:7422/
EOF
fi

View File

@@ -23,6 +23,9 @@ type Domain struct {
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsCrowdSec *bool `gorm:"-"`
// SupportsAppSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsAppSec *bool `gorm:"-"`
// SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
SupportsPrivate *bool `gorm:"-"`
}

View File

@@ -49,6 +49,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
SupportsCustomPorts: d.SupportsCustomPorts,
RequireSubdomain: d.RequireSubdomain,
SupportsCrowdsec: d.SupportsCrowdSec,
SupportsAppsec: d.SupportsAppSec,
SupportsPrivate: d.SupportsPrivate,
}
if d.TargetCluster != "" {

View File

@@ -39,6 +39,7 @@ type proxyManager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -98,6 +99,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
d.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, cluster)
d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
ret = append(ret, d)
}
@@ -115,6 +117,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
cd.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, d.TargetCluster)
cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
}
// Custom domains never require a subdomain by default since

View File

@@ -46,6 +46,10 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *mockProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -19,6 +19,7 @@ type Manager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)

View File

@@ -21,6 +21,7 @@ type store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
@@ -138,6 +139,13 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
}
// ClusterSupportsAppSec returns whether all active proxies in the cluster have
// a CrowdSec AppSec endpoint configured (unanimous). Returns nil when no proxy
// has reported capabilities.
func (m Manager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsAppSec(ctx, clusterAddr)
}
// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)

View File

@@ -99,6 +99,9 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -50,20 +50,6 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interfac
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterRequireSubdomain mocks base method.
func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -78,6 +64,20 @@ func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr)
}
// ClusterSupportsAppSec mocks base method.
func (m *MockManager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsAppSec indicates an expected call of ClusterSupportsAppSec.
func (mr *MockManagerMockRecorder) ClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsAppSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsAppSec), ctx, clusterAddr)
}
// ClusterSupportsCrowdSec mocks base method.
func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -92,6 +92,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterSupportsPrivate mocks base method.
func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -121,6 +135,35 @@ func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddre
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// Disconnect mocks base method.
func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error {
m.ctrl.T.Helper()
@@ -135,6 +178,21 @@ func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// GetActiveClusterAddresses mocks base method.
func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) {
m.ctrl.T.Helper()
@@ -150,6 +208,7 @@ func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx)
}
// GetActiveClusterAddressesForAccount mocks base method.
func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID)
@@ -158,6 +217,7 @@ func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, a
return ret0, ret1
}
// GetActiveClusterAddressesForAccount indicates an expected call of GetActiveClusterAddressesForAccount.
func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID)
@@ -177,36 +237,6 @@ func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// IsClusterAddressAvailable mocks base method.
func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) {
m.ctrl.T.Helper()
@@ -222,20 +252,6 @@ func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// MockController is a mock of Controller interface.
type MockController struct {
ctrl *gomock.Controller

View File

@@ -20,6 +20,9 @@ type Capabilities struct {
RequireSubdomain *bool
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
SupportsCrowdsec *bool
// SupportsAppsec indicates whether this proxy has a CrowdSec AppSec (WAF)
// endpoint configured.
SupportsAppsec *bool
// Private indicates whether this proxy supports inbound access via Wireguard
// tunnel and netbird-only authentication policies
Private *bool
@@ -74,5 +77,6 @@ type Cluster struct {
SupportsCustomPorts *bool
RequireSubdomain *bool
SupportsCrowdSec *bool
SupportsAppSec *bool
Private *bool
}

View File

@@ -204,6 +204,7 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdSec,
SupportsAppsec: c.SupportsAppSec,
Private: c.Private,
})
}

View File

@@ -82,6 +82,7 @@ type CapabilityProvider interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -137,6 +138,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
clusters[i].SupportsAppSec = m.capabilities.ClusterSupportsAppSec(ctx, clusters[i].Address)
clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
}

View File

@@ -165,6 +165,22 @@ type AccessRestrictions struct {
AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"`
BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"`
CrowdSecMode string `json:"crowdsec_mode,omitempty" gorm:"serializer:json"`
// AllowMatch controls how the allowlists combine: "" or "all" require
// matching every allowlist (AND), "any" requires matching at least one (OR).
// Empty is treated as "all" for backward compatibility with existing records.
AllowMatch string `json:"allow_match,omitempty" gorm:"serializer:json"`
// AppSecMode is the CrowdSec AppSec (WAF) request inspection mode: "",
// "off", "enforce", or "observe". HTTP services only.
AppSecMode string `json:"appsec_mode,omitempty" gorm:"serializer:json"`
}
// isEmpty reports whether no restriction is configured. Both conversions drop
// the object entirely in that case, so a field missing from this check is
// silently discarded on the way to the API and the proxy.
func (r AccessRestrictions) isEmpty() bool {
return len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" && r.AllowMatch == "" && r.AppSecMode == ""
}
// Copy returns a deep copy of the AccessRestrictions.
@@ -175,6 +191,8 @@ func (r AccessRestrictions) Copy() AccessRestrictions {
AllowedCountries: slices.Clone(r.AllowedCountries),
BlockedCountries: slices.Clone(r.BlockedCountries),
CrowdSecMode: r.CrowdSecMode,
AllowMatch: r.AllowMatch,
AppSecMode: r.AppSecMode,
}
}
@@ -808,13 +826,23 @@ func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error)
}
res.CrowdSecMode = string(*r.CrowdsecMode)
}
if r.AllowMatch != nil {
if !r.AllowMatch.Valid() {
return AccessRestrictions{}, fmt.Errorf("invalid allow_match %q", *r.AllowMatch)
}
res.AllowMatch = string(*r.AllowMatch)
}
if r.AppsecMode != nil {
if !r.AppsecMode.Valid() {
return AccessRestrictions{}, fmt.Errorf("invalid appsec_mode %q", *r.AppsecMode)
}
res.AppSecMode = string(*r.AppsecMode)
}
return res, nil
}
func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
if r.isEmpty() {
return nil
}
res := &api.AccessRestrictions{}
@@ -834,13 +862,19 @@ func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode)
res.CrowdsecMode = &mode
}
if r.AllowMatch != "" {
match := api.AccessRestrictionsAllowMatch(r.AllowMatch)
res.AllowMatch = &match
}
if r.AppSecMode != "" {
mode := api.AccessRestrictionsAppsecMode(r.AppSecMode)
res.AppsecMode = &mode
}
return res
}
func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
if r.isEmpty() {
return nil
}
return &proto.AccessRestrictions{
@@ -849,6 +883,8 @@ func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
AllowedCountries: r.AllowedCountries,
BlockedCountries: r.BlockedCountries,
CrowdsecMode: r.CrowdSecMode,
AllowMatch: r.AllowMatch,
AppsecMode: r.AppSecMode,
}
}
@@ -874,6 +910,11 @@ func (s *Service) Validate() error {
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
return err
}
// AppSec inspects HTTP requests, so it cannot apply to the L4 modes, which
// forward opaque byte streams.
if appSecEnabled(s.Restrictions.AppSecMode) && s.Mode != ModeHTTP {
return fmt.Errorf("appsec_mode is only supported for HTTP services, got mode %q", s.Mode)
}
if err := s.validatePrivateRequirements(); err != nil {
return err
}
@@ -1242,10 +1283,39 @@ func validateCrowdSecMode(mode string) error {
}
}
func validateAllowMatch(mode string) error {
switch mode {
case "", "all", "any":
return nil
default:
return fmt.Errorf("allow_match %q is invalid", mode)
}
}
func validateAppSecMode(mode string) error {
switch mode {
case "", "off", "enforce", "observe":
return nil
default:
return fmt.Errorf("appsec_mode %q is invalid", mode)
}
}
// appSecEnabled reports whether the mode asks for request inspection.
func appSecEnabled(mode string) bool {
return mode == "enforce" || mode == "observe"
}
func validateAccessRestrictions(r *AccessRestrictions) error {
if err := validateCrowdSecMode(r.CrowdSecMode); err != nil {
return err
}
if err := validateAllowMatch(r.AllowMatch); err != nil {
return err
}
if err := validateAppSecMode(r.AppSecMode); err != nil {
return err
}
if len(r.AllowedCIDRs) > maxCIDREntries {
return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries)

View File

@@ -26,6 +26,17 @@ func validProxy() *Service {
}
}
// validL4Proxy returns a service that passes validation in one of the L4 modes.
func validL4Proxy(mode string) *Service {
rp := validProxy()
rp.Mode = mode
rp.ListenPort = 9000
rp.Targets = []*Target{
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 5432, Protocol: mode, Enabled: true},
}
return rp
}
func TestValidate_Valid(t *testing.T) {
require.NoError(t, validProxy().Validate())
}
@@ -1302,6 +1313,65 @@ func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
require.NoError(t, rp.Validate())
}
func TestRestrictions_AllowMatch_RoundTrip(t *testing.T) {
anyMatch := api.AccessRestrictionsAllowMatchAny
apiIn := &api.AccessRestrictions{
AllowedCidrs: &[]string{"203.0.113.0/24"},
AllowedCountries: &[]string{"US"},
AllowMatch: &anyMatch,
}
model, err := restrictionsFromAPI(apiIn)
require.NoError(t, err)
assert.Equal(t, "any", model.AllowMatch)
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut.AllowMatch)
assert.Equal(t, api.AccessRestrictionsAllowMatchAny, *apiOut.AllowMatch)
protoOut := restrictionsToProto(model)
require.NotNil(t, protoOut)
assert.Equal(t, "any", protoOut.AllowMatch)
}
func TestRestrictions_AllowMatch_EmptyDefaultsToAll(t *testing.T) {
// A stored record with no allow_match (existing services) stays empty and
// must not surface a value on the API, preserving AND behavior downstream.
model, err := restrictionsFromAPI(&api.AccessRestrictions{
AllowedCidrs: &[]string{"203.0.113.0/24"},
})
require.NoError(t, err)
assert.Empty(t, model.AllowMatch, "unset allow_match stays empty")
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut)
assert.Nil(t, apiOut.AllowMatch, "empty allow_match is omitted from the API response")
}
func TestRestrictions_AllowMatchOnly_Preserved(t *testing.T) {
// allow_match set without any list must not be dropped by the emptiness
// guards, so it round-trips through both the API and proto conversions.
model := AccessRestrictions{AllowMatch: "any"}
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut, "allow-match-only restriction must not be omitted from the API response")
require.NotNil(t, apiOut.AllowMatch)
assert.Equal(t, api.AccessRestrictionsAllowMatchAny, *apiOut.AllowMatch)
protoOut := restrictionsToProto(model)
require.NotNil(t, protoOut, "allow-match-only restriction must not be omitted from the proto output")
assert.Equal(t, "any", protoOut.AllowMatch)
}
func TestValidate_RejectsInvalidAllowMatch(t *testing.T) {
rp := validProxy()
rp.Restrictions = AccessRestrictions{
AllowedCIDRs: []string{"203.0.113.0/24"},
AllowMatch: "sometimes",
}
assert.ErrorContains(t, rp.Validate(), "allow_match")
}
func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
rp := validProxy()
rp.Private = true
@@ -1315,3 +1385,68 @@ func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
}}
assert.ErrorContains(t, rp.Validate(), "HTTP")
}
func TestRestrictions_AppSecMode_RoundTrip(t *testing.T) {
mode := api.AccessRestrictionsAppsecModeEnforce
apiIn := &api.AccessRestrictions{AppsecMode: &mode}
model, err := restrictionsFromAPI(apiIn)
require.NoError(t, err)
assert.Equal(t, "enforce", model.AppSecMode)
// appsec_mode alone must keep the restrictions object alive on both the API
// and proto legs: it is meaningful without any CIDR or country entry.
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut, "appsec_mode alone must not collapse the restrictions to nil")
require.NotNil(t, apiOut.AppsecMode)
assert.Equal(t, api.AccessRestrictionsAppsecModeEnforce, *apiOut.AppsecMode)
protoOut := restrictionsToProto(model)
require.NotNil(t, protoOut, "appsec_mode alone must reach the proxy")
assert.Equal(t, "enforce", protoOut.AppsecMode)
}
func TestRestrictions_AppSecMode_EmptyIsOmitted(t *testing.T) {
model, err := restrictionsFromAPI(&api.AccessRestrictions{
AllowedCidrs: &[]string{"203.0.113.0/24"},
})
require.NoError(t, err)
assert.Empty(t, model.AppSecMode)
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut)
assert.Nil(t, apiOut.AppsecMode, "empty appsec_mode is omitted from the API response")
}
func TestRestrictions_AppSecMode_CopyIsDeep(t *testing.T) {
original := AccessRestrictions{AppSecMode: "observe", CrowdSecMode: "enforce"}
assert.Equal(t, original, original.Copy(), "Copy must carry every mode field")
}
func TestValidate_RejectsInvalidAppSecMode(t *testing.T) {
rp := validProxy()
rp.Restrictions = AccessRestrictions{AppSecMode: "sometimes"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode")
}
func TestValidate_RejectsAppSecOnL4Modes(t *testing.T) {
// AppSec inspects HTTP requests, so the L4 modes cannot honor it. Accepting
// the field there would report protection that never runs.
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "enforce"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode is only supported for HTTP services")
})
}
}
func TestValidate_AllowsAppSecOffOnL4Modes(t *testing.T) {
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "off"}
require.NoError(t, rp.Validate(), "an explicit off must not be rejected on L4 services")
})
}
}

View File

@@ -562,6 +562,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdsec,
SupportsAppsec: c.SupportsAppsec,
Private: c.Private,
}
}

View File

@@ -64,6 +64,19 @@ func (h *handler) createSetupKey(w http.ResponseWriter, r *http.Request) {
return
}
// A one-off key can be used once, and GenerateSetupKey pins its usage limit
// at 1 whatever the request says. Silently overriding a caller that asked
// for a different number leaves them holding a key that does not do what
// they configured, and no way to find out except by using it. Only values
// above 1 are refused: usage_limit is a required field with no null, so 0
// cannot be told apart from a caller that has nothing to say about it.
if types.SetupKeyType(req.Type) == types.SetupKeyOneOff && req.UsageLimit > 1 {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument,
"usage_limit %d is not valid for a one-off setup key, which can be used once; use type reusable for a key that can be used more than once",
req.UsageLimit), w)
return
}
expiresIn := time.Duration(req.ExpiresIn) * time.Second
if expiresIn < 0 {

View File

@@ -134,6 +134,40 @@ func TestSetupKeysHandlers(t *testing.T) {
expectedBody: true,
expectedSetupKey: expectedNewKey,
},
{
// A one-off key is used once. Asking for more used to be accepted
// and then quietly reduced to 1.
name: "Create One-Off Setup Key With Conflicting Usage Limit",
requestType: http.MethodPost,
requestPath: "/api/setup-keys",
requestBody: bytes.NewBuffer(
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
expectedStatus: http.StatusUnprocessableEntity,
expectedBody: false,
},
{
// 0 is what a caller sends when it has nothing to say about the
// usage limit, since the field is required and has no null, so it
// has to keep working.
name: "Create One-Off Setup Key Without Usage Limit",
requestType: http.MethodPost,
requestPath: "/api/setup-keys",
requestBody: bytes.NewBuffer(
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":0}", newSetupKeyName))),
expectedStatus: http.StatusOK,
expectedBody: false,
},
{
// Only one-off keys are constrained; a reusable key means what it
// says.
name: "Create Reusable Setup Key With Usage Limit",
requestType: http.MethodPost,
requestPath: "/api/setup-keys",
requestBody: bytes.NewBuffer(
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"reusable\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
expectedStatus: http.StatusOK,
expectedBody: false,
},
{
name: "Update Setup Key",
requestType: http.MethodPut,

View File

@@ -136,7 +136,10 @@ func Test_SetupKeys_Create(t *testing.T) {
},
},
{
name: "Create Setup Key as on-off with more than one usage",
// The key used to be created anyway, with its usage limit quietly
// reduced to 1, so the caller was told a key they had not asked for
// was what they asked for.
name: "Create Setup Key as one-off with more than one usage",
requestType: http.MethodPost,
requestPath: "/api/setup-keys",
requestBody: &api.CreateSetupKeyRequest{
@@ -146,23 +149,7 @@ func Test_SetupKeys_Create(t *testing.T) {
Type: "one-off",
UsageLimit: 3,
},
expectedStatus: http.StatusOK,
expectedResponse: &api.SetupKey{
AutoGroups: []string{},
Ephemeral: false,
Expires: time.Time{},
Id: "",
Key: "",
LastUsed: time.Time{},
Name: testing_tools.NewKeyName,
Revoked: false,
State: "valid",
Type: "one-off",
UpdatedAt: time.Now(),
UsageLimit: 1,
UsedTimes: 0,
Valid: true,
},
expectedStatus: http.StatusUnprocessableEntity,
},
{
name: "Create Setup Key with expiration in the past",

View File

@@ -6491,6 +6491,7 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
"supports_appsec": {},
"private": {},
}
@@ -6521,6 +6522,14 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec")
}
// GetClusterSupportsAppSec returns whether all active proxies in the cluster
// have a CrowdSec AppSec endpoint configured. Returns nil when no proxy
// reported the capability. Unanimous for the same reason as CrowdSec: a single
// proxy without AppSec would let requests through uninspected.
func (s *SqlStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_appsec")
}
// getClusterUnanimousCapability returns an aggregated boolean capability
// requiring all active proxies in the cluster to report true.
func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool {

View File

@@ -0,0 +1,119 @@
package store
import (
"context"
"fmt"
"os"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
)
// Capabilities travel proxy → gRPC → embedded gorm columns → aggregation → API.
// A field dropped at any of those hops reads as "capability absent", which is
// indistinguishable from a proxy that never reported it: the dashboard simply
// hides the feature and nothing fails. These assertions cover the persistence
// and aggregation hops.
func TestSqlStore_ClusterCapabilityAggregation(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
yes, no := true, false
tests := []struct {
name string
reported []*bool // one entry per connected proxy in the cluster
wantAppSec *bool
wantAssertion string
}{
{
name: "unreported stays unknown",
reported: []*bool{nil},
wantAppSec: nil,
wantAssertion: "an unreported capability must not read as false",
},
{
name: "single proxy reporting true",
reported: []*bool{&yes},
wantAppSec: &yes,
wantAssertion: "a reported capability must survive persistence",
},
{
name: "one proxy without it disables the cluster",
reported: []*bool{&yes, &no},
wantAppSec: &no,
wantAssertion: "capability must be unanimous, so a rolling upgrade cannot leave traffic uninspected",
},
{
name: "one proxy yet to report disables the cluster",
reported: []*bool{&yes, nil},
wantAppSec: &no,
wantAssertion: "a proxy that has not reported must not count as capable",
},
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster := fmt.Sprintf("cluster-%d.proxy.example", i)
for j, reported := range tt.reported {
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
ID: fmt.Sprintf("proxy-%d-%d", i, j),
ClusterAddress: cluster,
Status: proxy.StatusConnected,
LastSeen: time.Now(),
Capabilities: proxy.Capabilities{SupportsAppsec: reported},
}))
}
got := store.GetClusterSupportsAppSec(ctx, cluster)
if tt.wantAppSec == nil {
assert.Nil(t, got, tt.wantAssertion)
return
}
require.NotNil(t, got, tt.wantAssertion)
assert.Equal(t, *tt.wantAppSec, *got, tt.wantAssertion)
})
}
})
}
// AppSec and IP reputation are separate endpoints, so a cluster can have either
// without the other. Gating one on the other would silently disable a feature
// the operator configured.
func TestSqlStore_ClusterCapabilitiesAreIndependent(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
const cluster = "independent.proxy.example"
yes, no := true, false
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
ID: "proxy-independent",
ClusterAddress: cluster,
Status: proxy.StatusConnected,
LastSeen: time.Now(),
Capabilities: proxy.Capabilities{
SupportsAppsec: &yes,
SupportsCrowdsec: &no,
},
}))
appsec := store.GetClusterSupportsAppSec(ctx, cluster)
crowdsec := store.GetClusterSupportsCrowdSec(ctx, cluster)
require.NotNil(t, appsec)
require.NotNil(t, crowdsec)
assert.True(t, *appsec, "AppSec must not be gated on CrowdSec")
assert.False(t, *crowdsec, "CrowdSec must not be implied by AppSec")
})
}

View File

@@ -133,3 +133,46 @@ func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) {
assert.True(t, tg.Options.DisableAccessLog, "options disable access log")
})
}
func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
account := newAccountWithId(ctx, "account_svc_restrictions", "testuser", "")
require.NoError(t, store.SaveAccount(ctx, account))
svc := &rpservice.Service{
ID: "svc-restrictions",
AccountID: account.Id,
Name: "restricted-svc",
Domain: "restricted.example",
Enabled: true,
Mode: rpservice.ModeHTTP,
Restrictions: rpservice.AccessRestrictions{
AllowedCIDRs: []string{"203.0.113.0/24"},
AllowedCountries: []string{"US"},
AllowMatch: "any",
CrowdSecMode: "observe",
AppSecMode: "enforce",
},
}
require.NoError(t, store.CreateService(ctx, svc))
loaded, err := store.GetAccount(ctx, account.Id)
require.NoError(t, err)
require.Len(t, loaded.Services, 1)
// Restrictions are stored as a JSON blob; confirm the whole struct,
// including allow_match and the CrowdSec modes, survives the read path
// (Postgres pgx path included via runTestForAllEngines).
got := loaded.Services[0].Restrictions
assert.Equal(t, []string{"203.0.113.0/24"}, got.AllowedCIDRs)
assert.Equal(t, []string{"US"}, got.AllowedCountries)
assert.Equal(t, "any", got.AllowMatch)
assert.Equal(t, "observe", got.CrowdSecMode)
assert.Equal(t, "enforce", got.AppSecMode)
})
}

View File

@@ -329,6 +329,7 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error)

View File

@@ -1878,6 +1878,20 @@ func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr)
}
// GetClusterSupportsAppSec mocks base method.
func (m *MockStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// GetClusterSupportsAppSec indicates an expected call of GetClusterSupportsAppSec.
func (mr *MockStoreMockRecorder) GetClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsAppSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsAppSec), ctx, clusterAddr)
}
// GetClusterSupportsCrowdSec mocks base method.
func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()

View File

@@ -79,6 +79,11 @@ var (
geoDataDir string
crowdsecAPIURL string
crowdsecAPIKey string
appsecURL string
appsecTimeout time.Duration
appsecMaxBodyBytes int64
captureBudgetBytes int64
appsecMaxConcurrent int
)
var rootCmd = &cobra.Command{
@@ -125,6 +130,11 @@ func init() {
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
rootCmd.Flags().StringVar(&crowdsecAPIURL, "crowdsec-api-url", envStringOrDefault("NB_PROXY_CROWDSEC_API_URL", ""), "CrowdSec LAPI URL for IP reputation checks")
rootCmd.Flags().StringVar(&crowdsecAPIKey, "crowdsec-api-key", envStringOrDefault("NB_PROXY_CROWDSEC_API_KEY", ""), "CrowdSec bouncer API key")
rootCmd.Flags().StringVar(&appsecURL, "crowdsec-appsec-url", envStringOrDefault("NB_PROXY_CROWDSEC_APPSEC_URL", ""), "CrowdSec AppSec (WAF) endpoint for HTTP request inspection, e.g. http://127.0.0.1:7422/ (reuses the bouncer API key)")
rootCmd.Flags().DurationVar(&appsecTimeout, "crowdsec-appsec-timeout", envDurationOrDefault("NB_PROXY_CROWDSEC_APPSEC_TIMEOUT", 0), "Timeout for a single AppSec inspection call (0 = 200ms)")
rootCmd.Flags().IntVar(&appsecMaxConcurrent, "crowdsec-appsec-max-concurrent", int(envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_CONCURRENT", 0)), "Cap on AppSec inspections in flight; further requests are denied in enforce mode rather than queued (0 = 256, negative = no cap)")
rootCmd.Flags().Int64Var(&captureBudgetBytes, "capture-budget-bytes", envInt64OrDefault("NB_PROXY_CAPTURE_BUDGET_BYTES", 0), "Total in-flight request-body buffering across the proxy, shared by AppSec inspection and agent-network capture (0 = 256MiB)")
rootCmd.Flags().Int64Var(&appsecMaxBodyBytes, "crowdsec-appsec-max-body-bytes", envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_BODY_BYTES", 0), "Cap on the request body mirrored to AppSec (0 = 64KiB, negative = headers and URI only)")
}
// Execute runs the root command.
@@ -218,47 +228,59 @@ func runServer(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := proxy.New(ctx, proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: parsedTrustedProxies,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
})
srv := proxy.New(ctx, serverConfig(logger, proxyToken, parsedTrustedProxies, perf))
return srv.ListenAndServe(ctx, addr)
}
// serverConfig maps the parsed flags and environment onto the proxy config.
// Kept separate from runServer so registering a new flag does not grow the
// startup path.
func serverConfig(logger *log.Logger, proxyToken string, trustedProxyList *trustedproxy.List, perf embed.Performance) proxy.Config {
return proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: trustedProxyList,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
CrowdSecAppSecURL: appsecURL,
CrowdSecAppSecTimeout: appsecTimeout,
CrowdSecAppSecMaxBodyBytes: appsecMaxBodyBytes,
CrowdSecAppSecMaxConcurrent: appsecMaxConcurrent,
MiddlewareCaptureBudgetBytes: captureBudgetBytes,
}
}
func envBoolOrDefault(key string, def bool) bool {
v, exists := os.LookupEnv(key)
if !exists {
@@ -293,6 +315,19 @@ func envUint16OrDefault(key string, def uint16) uint16 {
return uint16(parsed)
}
func envInt64OrDefault(key string, def int64) int64 {
v, exists := os.LookupEnv(key)
if !exists {
return def
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def)
return def
}
return parsed
}
func envDurationOrDefault(key string, def time.Duration) time.Duration {
v, exists := os.LookupEnv(key)
if !exists {

View File

@@ -0,0 +1,163 @@
package appsec
import (
"bytes"
"errors"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
)
// bufferBody reads up to limit+1 bytes from r.Body and always restores r.Body so
// the request stays forwardable. oversize reports that the body exceeded limit, in
// which case the returned prefix must not be used for inspection: the bytes are
// only read so they can be replayed to the backend.
func bufferBody(r *http.Request, limit int64) (body []byte, oversize bool, err error) {
original := r.Body
buf, readErr := io.ReadAll(io.LimitReader(original, limit+1))
if readErr != nil && !errors.Is(readErr, io.EOF) {
// Restore what was read so a downstream retry sees a consistent stream,
// then surface the failure.
r.Body = replay(buf, original)
return nil, false, readErr
}
if int64(len(buf)) > limit {
r.Body = replay(buf, original)
return nil, true, nil
}
// The whole body is buffered, so the original is drained and can be closed.
// A close error on a drained read-only body does not invalidate the bytes.
_ = original.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
// Framing is deliberately left as the client sent it. Rewriting a chunked
// request to a fixed Content-Length here would be invisible to the client
// but not to the rest of the chain: a later body capture with a smaller cap
// sees a known length over its cap and skips capture entirely, where an
// unknown length would have given it a truncated prefix. Inspecting a
// request must not change what any other layer gets to inspect.
return buf, false, nil
}
// replay returns a ReadCloser that yields the already-read prefix followed by
// the remainder of the original stream, and closes the original.
func replay(prefix []byte, rest io.ReadCloser) io.ReadCloser {
return struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
Closer: rest,
}
}
// redactedPlaceholder replaces a credential value in the mirrored body. It is
// inert for rule matching, and its fixed length leaks nothing about the secret.
const redactedPlaceholder = "redacted"
// redactFormFields returns the body to mirror for a URL-encoded form, with the
// values of the named fields replaced. The proxy's own password / PIN login
// form posts to the service path itself, so without this the plaintext
// credential would reach the Security Engine.
//
// Only the credential values are removed, never the whole body: dropping the
// body outright would let a caller exempt any payload from inspection just by
// appending a field named "password". Everything else in the form stays
// inspectable, which is the point.
//
// Returns body unchanged when it is not a URL-encoded form or carries none of
// the fields.
//
// Substitution happens on the raw bytes rather than by re-encoding parsed
// values. Re-encoding would drop pairs that url.ParseQuery rejects, so a
// payload hidden in a malformed pair alongside a credential-named field would
// never be inspected while a tolerant backend parser still acted on it. Working
// byte-wise also avoids reordering keys and normalizing escapes, so the engine
// sees the same bytes the backend will.
//
// Field names match case-sensitively, on purpose: the caller passes the exact
// names the login handler reads via r.FormValue, and that lookup is itself
// case-sensitive. A "Password" field is therefore never a credential as far as
// the proxy is concerned, and redacting it would only blind the WAF to a value
// the proxy does not own.
func redactFormFields(contentType string, body []byte, fields []string) []byte {
if len(fields) == 0 || len(body) == 0 {
return body
}
media, _, err := mime.ParseMediaType(contentType)
if err != nil || media != "application/x-www-form-urlencoded" {
return body
}
return redactURLEncoded(body, fields)
}
// redactURLEncoded replaces the values of the named keys in a URL-encoded
// key/value sequence, the shared syntax of a query string and a form body.
func redactURLEncoded(raw []byte, fields []string) []byte {
// Split on "&" only, matching how Go's form parser delimits pairs.
segments := bytes.Split(raw, []byte("&"))
redacted := false
for i, segment := range segments {
rawKey, _, hasValue := bytes.Cut(segment, []byte("="))
if !hasValue {
continue
}
// Compare the decoded name, so an escaped spelling of the field
// ("pass%77ord") is redacted too: the reader decodes before looking it
// up. A key that fails to decode never reaches that reader either,
// since the parser drops the pair.
name, err := url.QueryUnescape(string(rawKey))
if err != nil || !slices.Contains(fields, name) {
continue
}
// Keep the key bytes as sent and replace only the value. Assigning a
// fresh slice leaves raw untouched, which matters: the caller restored
// the request body from the same buffer.
segments[i] = []byte(string(rawKey) + "=" + redactedPlaceholder)
redacted = true
}
if !redacted {
return raw
}
return bytes.Join(segments, []byte("&"))
}
// redactQuery replaces the values of the named query parameters in a raw query
// string, leaving every other byte as sent.
func redactQuery(rawQuery string, params []string) string {
if len(params) == 0 || rawQuery == "" {
return rawQuery
}
return string(redactURLEncoded([]byte(rawQuery), params))
}
// redactCookieHeader replaces the values of the named cookies in a Cookie
// header, keeping the others intact: cookies are a zone WAF rules match on, so
// dropping the whole header would cost real coverage.
func redactCookieHeader(value string, names []string) string {
if len(names) == 0 || value == "" {
return value
}
parts := strings.Split(value, ";")
redacted := false
for i, part := range parts {
name, _, hasValue := strings.Cut(part, "=")
if !hasValue {
continue
}
// Cookie names are case-sensitive and are not percent-decoded.
if !slices.Contains(names, strings.TrimSpace(name)) {
continue
}
parts[i] = name + "=" + redactedPlaceholder
redacted = true
}
if !redacted {
return value
}
return strings.Join(parts, ";")
}

View File

@@ -0,0 +1,571 @@
// Package appsec implements the CrowdSec AppSec (WAF) side of the remediation
// component protocol: each inspected HTTP request is mirrored to the Security
// Engine's AppSec endpoint, which replies with an allow / ban / captcha verdict
// for that request.
//
// This is a separate endpoint from the LAPI decision stream used by the
// crowdsec package: LAPI answers "is this IP known bad", AppSec answers "is
// this request an attack". The two are configured and enabled independently.
package appsec
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/proxy/internal/netutil"
"github.com/netbirdio/netbird/proxy/internal/restrict"
)
// Header names the AppSec component reads off the mirrored request. IP, URI and
// Verb are mandatory: the engine answers 500 when any of them is missing.
const (
headerAPIKey = "X-Crowdsec-Appsec-Api-Key" //nolint:gosec // G101: a header name, not a credential
headerIP = "X-Crowdsec-Appsec-Ip"
headerURI = "X-Crowdsec-Appsec-Uri"
headerVerb = "X-Crowdsec-Appsec-Verb"
headerHost = "X-Crowdsec-Appsec-Host"
headerUserAgent = "X-Crowdsec-Appsec-User-Agent"
headerHTTPVersion = "X-Crowdsec-Appsec-Http-Version"
headerTransactionID = "X-Crowdsec-Appsec-Transaction-Id"
)
// headerPrefix covers every protocol header. Any client-supplied header in this
// namespace is dropped before forwarding so a caller cannot influence the
// engine's view of its own address, or replay an API key.
const headerPrefix = "X-Crowdsec-Appsec-"
// Remediation actions the engine can return.
const (
actionAllow = "allow"
actionBan = "ban"
actionCaptcha = "captcha"
)
const (
// DefaultTimeout matches the 200ms budget CrowdSec's remediation component
// spec sets for the blocking AppSec call.
DefaultTimeout = 200 * time.Millisecond
// MinTimeout and MaxTimeout bound the configured inspection timeout.
// Inspection is synchronous, so the upper bound is what keeps a
// mis-set value from parking every request to an inspected service on a
// slow engine; the lower bound keeps the call from timing out before the
// engine can realistically answer. Mirrors the per-middleware bounds the
// proxy already applies to in-path calls.
MinTimeout = 10 * time.Millisecond
MaxTimeout = 5 * time.Second
// DefaultMaxBodyBytes caps the request body mirrored to the engine.
// Requests with a larger body are inspected on headers and URI only.
DefaultMaxBodyBytes int64 = 64 << 10
// DefaultMaxConcurrent bounds inspections in flight toward the engine. The
// point is to fail fast instead of parking a goroutine per request for the
// whole timeout once the engine is saturated: a slow engine otherwise turns
// a traffic burst into a pile of waiters that all time out anyway. Sized so
// a healthy engine (single-digit milliseconds per call) never reaches it.
DefaultMaxConcurrent = 256
// MaxConcurrentLimit is the ceiling for that bound.
MaxConcurrentLimit = 4096
// MaxBodyBytesLimit is the ceiling for that cap. A single request can hold
// this much in memory; the shared Budget is what bounds the total across
// concurrent requests. Matches the proxy-wide body-capture ceiling.
MaxBodyBytesLimit int64 = 8 << 20
// maxResponseBytes bounds how much of a verdict response is read. The
// engine answers with a two-field JSON object, so anything beyond this is
// not a response we can act on.
maxResponseBytes int64 = 4 << 10
)
// Reasons the request body was not mirrored. Reported so an access-log reader
// can distinguish "inspected and clean" from "never inspected", and so an
// oversize opt-out is visible rather than silent.
const (
BypassOversize = "oversize"
BypassUpgrade = "upgrade"
BypassDisabled = "disabled"
BypassBudget = "budget_exhausted"
)
// ErrUnavailable reports that the engine could not produce a verdict: the call
// failed, timed out, or the engine rejected it (401 bad key, 500 malformed).
// Distinguished from a block verdict so the caller can apply the per-service
// mode: enforce fails closed, observe allows.
var ErrUnavailable = errors.New("appsec engine unavailable")
// Config configures a Client.
type Config struct {
// URL is the AppSec endpoint, e.g. http://127.0.0.1:7422/.
URL string
// APIKey is the CrowdSec bouncer API key. The AppSec component validates it
// against LAPI, so the same key used for the decision stream works here.
APIKey string
// Timeout bounds a single inspection call. Zero means DefaultTimeout.
Timeout time.Duration
// MaxBodyBytes caps the mirrored request body. Zero means
// DefaultMaxBodyBytes; negative disables body forwarding entirely.
MaxBodyBytes int64
// MaxConcurrent bounds inspections in flight toward the engine. Zero means
// DefaultMaxConcurrent; negative disables the bound.
MaxConcurrent int
// Budget bounds the total body buffering in flight across all inspected
// requests. Nil disables that ceiling, which leaves the worst case at
// MaxBodyBytes times the concurrent request count; callers serving
// untrusted traffic should share the proxy-wide capture budget here.
Budget Budget
Logger *log.Entry
}
// Budget is the shared allowance for in-flight body buffering. Acquire reports
// whether n bytes could be reserved; every successful Acquire is matched by a
// Release of the same n. Satisfied by the proxy's capture budget, so AppSec and
// the middleware body tap draw down one pool rather than two independent ones.
type Budget interface {
Acquire(n int64) bool
Release(n int64)
}
// Client mirrors HTTP requests to a CrowdSec AppSec endpoint. It holds no
// per-service state and is safe for concurrent use.
type Client struct {
url string
apiKey string
maxBodyBytes int64
// sem bounds in-flight inspections. Nil when the bound is disabled.
sem chan struct{}
budget Budget
http *http.Client
logger *log.Entry
}
// New validates the config and returns a Client. The endpoint is not contacted
// here: the engine may come up after the proxy.
func New(cfg Config) (*Client, error) {
if cfg.URL == "" {
return nil, errors.New("appsec url is empty")
}
if cfg.APIKey == "" {
return nil, errors.New("appsec api key is empty")
}
parsed, err := url.Parse(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse appsec url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("appsec url scheme %q is not http(s)", parsed.Scheme)
}
if parsed.Host == "" {
return nil, errors.New("appsec url has no host")
}
logger := cfg.Logger
if logger == nil {
logger = log.NewEntry(log.StandardLogger())
}
timeout := cfg.Timeout
switch {
case timeout <= 0:
timeout = DefaultTimeout
case timeout < MinTimeout:
logger.Warnf("appsec timeout %s is below the minimum, using %s", timeout, MinTimeout)
timeout = MinTimeout
case timeout > MaxTimeout:
logger.Warnf("appsec timeout %s exceeds the maximum, using %s", timeout, MaxTimeout)
timeout = MaxTimeout
}
// A negative cap is meaningful: forward no body at all.
maxBody := cfg.MaxBodyBytes
switch {
case maxBody == 0:
maxBody = DefaultMaxBodyBytes
case maxBody > MaxBodyBytesLimit:
logger.Warnf("appsec max body %d exceeds the maximum, using %d", maxBody, MaxBodyBytesLimit)
maxBody = MaxBodyBytesLimit
}
maxConcurrent := cfg.MaxConcurrent
switch {
case maxConcurrent == 0:
maxConcurrent = DefaultMaxConcurrent
case maxConcurrent > MaxConcurrentLimit:
logger.Warnf("appsec max concurrent %d exceeds the maximum, using %d", maxConcurrent, MaxConcurrentLimit)
maxConcurrent = MaxConcurrentLimit
}
var sem chan struct{}
if maxConcurrent > 0 {
sem = make(chan struct{}, maxConcurrent)
}
return &Client{
url: cfg.URL,
apiKey: cfg.APIKey,
maxBodyBytes: maxBody,
sem: sem,
budget: cfg.Budget,
logger: logger,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
},
},
}, nil
}
// Request is one inspection request.
type Request struct {
// HTTP is the in-flight client request. Inspect buffers and restores its
// body, so the request stays forwardable afterwards.
HTTP *http.Request
// ClientIP is the resolved client address (after trusted-proxy handling).
ClientIP netip.Addr
// TransactionID correlates the engine's alert with the proxy's access log
// entry. Empty lets the engine generate its own UUID.
TransactionID string
// RedactBodyFields lists form fields whose values are replaced before the
// body is mirrored. Used to keep credentials submitted to the proxy's own
// login form out of the engine while still inspecting the rest.
RedactBodyFields []string
// RedactHeaders, RedactCookies and RedactQueryParams name the credentials
// the proxy already withholds from backends: the header-auth values, its
// session cookie, and the OIDC session token. The engine logs and alerts on
// what it inspects, so mirroring them there would reintroduce the leak the
// upstream strippers exist to prevent. Only the values are replaced, so the
// surrounding headers, cookies and query stay inspectable.
RedactHeaders []string
RedactCookies []string
RedactQueryParams []string
}
// Result is the outcome of an inspection.
type Result struct {
Verdict restrict.Verdict
// BodyBypass names why the request body was not mirrored, empty when it
// was (or when the request had none). The engine still saw the headers and
// URI, so this is a coverage note, not a failure.
BodyBypass string
// Release returns the buffered body's budget reservation. Never nil, so it
// is always safe to defer. It must run only once the request has been
// served, not when Inspect returns: the buffer stays alive as r.Body for
// the backend to read, so releasing earlier would let the budget admit
// buffering that is still resident.
Release func()
}
// noopRelease is the Release for inspections that reserved no budget.
func noopRelease() {}
// Inspect mirrors r to the AppSec engine and returns its verdict. A nil error
// with restrict.Allow means the request passed. On failure it returns
// DenyAppSecUnavailable wrapped with ErrUnavailable; the caller decides whether
// that blocks, based on the per-service mode.
func (c *Client) Inspect(ctx context.Context, req Request) (Result, error) {
if c == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, ErrUnavailable
}
if req.HTTP == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, fmt.Errorf("%w: nil request", ErrUnavailable)
}
// release is carried out to the caller rather than deferred here: the
// buffered body outlives this call as r.Body.
if !c.acquireSlot() {
// Deny rather than wave through: a flood must not be a way to switch
// inspection off. Enforce blocks, observe logs and allows, exactly as
// for an unreachable engine.
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease},
fmt.Errorf("%w: %d inspections already in flight", ErrUnavailable, cap(c.sem))
}
defer c.releaseSlot()
body, bypass, release, err := c.readBody(req)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: release}, fmt.Errorf("%w: read body: %w", ErrUnavailable, err)
}
outbound, err := c.buildRequest(ctx, req, body)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
resp, err := c.http.Do(outbound)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
defer func() {
// Drain before closing. net/http only returns a connection to the idle
// pool once its body is read to EOF; closing with bytes outstanding
// discards it. Every verdict carries a JSON body, so skipping this
// would cost a fresh handshake per inspected request, inside the
// timeout budget.
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
c.logger.Tracef("drain appsec response body: %v", err)
}
if err := resp.Body.Close(); err != nil {
c.logger.Tracef("close appsec response body: %v", err)
}
}()
verdict, err := c.verdict(resp)
return Result{Verdict: verdict, BodyBypass: bypass, Release: release}, err
}
// acquireSlot takes an in-flight slot without blocking, reporting false when
// the engine is already at capacity.
func (c *Client) acquireSlot() bool {
if c.sem == nil {
return true
}
select {
case c.sem <- struct{}{}:
return true
default:
return false
}
}
// releaseSlot returns the slot. Scoped to the engine call, not the request: the
// buffered body outlives the call but the engine's attention does not.
func (c *Client) releaseSlot() {
if c.sem == nil {
return
}
select {
case <-c.sem:
default:
}
}
// readBody buffers the body so it can be mirrored, always restoring it on the
// original request. Returns nil when there is no body to forward: no body at
// all, an upgrade request, or a body over the cap. A login form is forwarded
// with its credential values redacted rather than suppressed.
// release is never nil; the caller invokes it once the request has been served.
func (c *Client) readBody(req Request) (body []byte, bypass string, release func(), err error) {
r := req.HTTP
if r.Body == nil || r.Body == http.NoBody {
return nil, "", noopRelease, nil
}
if c.maxBodyBytes < 0 {
return nil, BypassDisabled, noopRelease, nil
}
// A genuine upgrade request carries no body to inspect (net/http hands us
// http.NoBody, caught above); the hijacked stream is reached through
// Hijacker, never r.Body. The test has to be the forwarder's own, because a
// looser one would skip inspection for requests the forwarder still
// delivers to the backend with their body intact.
if netutil.IsUpgradeRequest(r.Header) {
return nil, BypassUpgrade, noopRelease, nil
}
// A Content-Length over the cap is known to be too large before reading.
if r.ContentLength > c.maxBodyBytes {
return nil, BypassOversize, noopRelease, nil
}
// Reserve the whole cap rather than the eventual length: the reservation
// has to be made before the body is read, and until then the only bound
// known is the cap. Skipping inspection when the pool is drained keeps a
// burst of large bodies from being an out-of-memory lever; the bypass is
// recorded so the gap in coverage is visible.
release = noopRelease
if c.budget != nil {
if !c.budget.Acquire(c.maxBodyBytes) {
c.logger.Debugf("appsec buffer budget exhausted, inspecting headers and URI only")
return nil, BypassBudget, noopRelease, nil
}
var once sync.Once
release = func() { once.Do(func() { c.budget.Release(c.maxBodyBytes) }) }
}
buffered, oversize, err := bufferBody(r, c.maxBodyBytes)
if err != nil {
// bufferBody restored r.Body from the bytes it did read, so the
// reservation stays held until the caller releases it.
return nil, "", release, err
}
// An oversize body was only partially read: a truncated prefix changes the
// engine's verdict in both directions, so inspect headers and URI only.
if oversize {
return nil, BypassOversize, release, nil
}
return redactFormFields(r.Header.Get("Content-Type"), buffered, req.RedactBodyFields), "", release, nil
}
// buildRequest assembles the mirrored request. Per the protocol it is a GET
// when there is no body and a POST otherwise; bytes.Reader gives the outbound
// request an accurate Content-Length, which the engine relies on to read the
// body at all.
func (c *Client) buildRequest(ctx context.Context, req Request, body []byte) (*http.Request, error) {
method := http.MethodGet
var payload io.Reader
if len(body) > 0 {
method = http.MethodPost
payload = bytes.NewReader(body)
}
outbound, err := http.NewRequestWithContext(ctx, method, c.url, payload)
if err != nil {
return nil, fmt.Errorf("build appsec request: %w", err)
}
r := req.HTTP
copyInspectableHeaders(outbound.Header, r.Header)
redactSecrets(outbound.Header, req)
outbound.Header.Set(headerAPIKey, c.apiKey)
outbound.Header.Set(headerIP, req.ClientIP.Unmap().String())
outbound.Header.Set(headerURI, mirroredURI(r.URL, req.RedactQueryParams))
outbound.Header.Set(headerVerb, r.Method)
outbound.Header.Set(headerHost, r.Host)
if ua := r.UserAgent(); ua != "" {
outbound.Header.Set(headerUserAgent, ua)
}
outbound.Header.Set(headerHTTPVersion, httpVersion(r))
if req.TransactionID != "" {
outbound.Header.Set(headerTransactionID, req.TransactionID)
}
return outbound, nil
}
// verdict maps the engine's response to a restrict.Verdict. 200 is a pass and
// 401/500 are engine-side failures; every other status carries a remediation in
// the body. The blocked status code is operator-configurable
// (blocked_http_code), so the action field decides, not the status.
func (c *Client) verdict(resp *http.Response) (restrict.Verdict, error) {
switch resp.StatusCode {
case http.StatusUnauthorized:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: rejected api key", ErrUnavailable)
case http.StatusInternalServerError:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: engine error", ErrUnavailable)
}
// Every status, 200 included, has to carry a decodable remediation. Taking a
// bare 200 as a pass would mean a URL pointing at anything that answers 200
// (a health endpoint, a load balancer's default page) silently allows every
// request while the service reports itself as enforcing.
var decoded struct {
Action string `json:"action"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&decoded); err != nil {
// Every remediation carries a decodable action, so a response without
// one is not a verdict: most often the URL points at something that is
// not the AppSec endpoint, which answers 404 with HTML. Reported as
// unavailable rather than a ban so the access log names the real fault
// instead of sending an operator hunting for a rule that never fired.
// Enforce still blocks either way; only the recorded reason differs.
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: undecodable response (status %d): %w", ErrUnavailable, resp.StatusCode, err)
}
switch decoded.Action {
case actionAllow:
return restrict.Allow, nil
case actionCaptcha:
return restrict.DenyAppSecCaptcha, nil
case actionBan:
return restrict.DenyAppSecBan, nil
case "":
// Decodable JSON without a remediation is not a verdict either: the
// endpoint answered, but not as the engine. Same reasoning as an
// undecodable body, and the same reason to point at configuration.
return restrict.DenyAppSecUnavailable,
fmt.Errorf("%w: response carried no remediation (status %d)", ErrUnavailable, resp.StatusCode)
default:
// A remediation we do not implement still means the engine flagged the
// request, so deny.
c.logger.Debugf("unknown appsec action %q (status %d), treating as ban", decoded.Action, resp.StatusCode)
return restrict.DenyAppSecBan, nil
}
}
// copyInspectableHeaders copies the client's headers, which are what the WAF
// rules actually match on, dropping hop-by-hop headers that describe the
// proxy-to-engine connection rather than the client request, and any header in
// the AppSec protocol namespace.
func copyInspectableHeaders(dst, src http.Header) {
for name, values := range src {
if hopByHopHeaders[http.CanonicalHeaderKey(name)] {
continue
}
if strings.HasPrefix(http.CanonicalHeaderKey(name), headerPrefix) {
continue
}
dst[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
}
// Content-Length describes the mirrored payload, not the client's: net/http
// sets it from the body we actually attach. Content-Type is kept either way
// so rules matching on it still fire when the body was not forwarded.
dst.Del("Content-Length")
}
// redactSecrets replaces the credential values the proxy withholds from
// backends, so the mirrored copy does not carry them either.
func redactSecrets(dst http.Header, req Request) {
for _, name := range req.RedactHeaders {
// Presence, not Get: a header whose first value is empty still carries
// its later values to the engine, while the upstream strip deletes the
// name outright. Set collapses every value into the placeholder.
if len(dst.Values(name)) > 0 {
dst.Set(name, redactedPlaceholder)
}
}
// Every Cookie line, not just the first: a client may send several, and Get
// would leave the session cookie in any later one mirrored in the clear.
if cookies := dst.Values("Cookie"); len(cookies) > 0 {
redacted := make([]string, len(cookies))
for i, cookie := range cookies {
redacted[i] = redactCookieHeader(cookie, req.RedactCookies)
}
dst["Cookie"] = redacted
}
}
// mirroredURI renders the request target for the URI header, with the named
// query parameter values replaced.
func mirroredURI(u *url.URL, redactParams []string) string {
uri := u.RequestURI()
if u.RawQuery == "" || len(redactParams) == 0 {
return uri
}
redacted := redactQuery(u.RawQuery, redactParams)
if redacted == u.RawQuery {
return uri
}
// RequestURI is path + "?" + RawQuery; swap only the query part so the
// path keeps its original encoding.
return strings.TrimSuffix(uri, u.RawQuery) + redacted
}
var hopByHopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Proxy-Connection": true,
"Te": true,
"Trailer": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// httpVersion renders the two-digit form the engine parses ("11", "20").
func httpVersion(r *http.Request) string {
major, minor := r.ProtoMajor, r.ProtoMinor
if major < 0 || major > 9 || minor < 0 || minor > 9 {
return ""
}
return fmt.Sprintf("%d%d", major, minor)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
package auth
import (
"crypto/ed25519"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
)
// appsecEngine is a stub AppSec component returning a fixed remediation.
func appsecEngine(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
if body != "" {
_, _ = w.Write([]byte(body))
}
}))
t.Cleanup(srv.Close)
return srv
}
// serveWithAppSec runs a request through the middleware for a domain configured
// with the given AppSec mode, returning the response and the captured metadata.
func serveWithAppSec(t *testing.T, mode restrict.AppSecMode, client *appsec.Client, r *http.Request) (*httptest.ResponseRecorder, map[string]string, bool) {
t.Helper()
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: mode,
}))
reached := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, r)
return rec, cd.GetMetadata(), reached
}
func appsecRequest() *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://svc.example.com/?x=/etc/passwd", nil)
r.Host = "svc.example.com"
r.RemoteAddr = "203.0.113.7:44444"
return r
}
func TestCheckAppSec_EnforceBlocksBannedRequest(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.NotContains(t, meta, "appsec_mode", "enforce is the default, only observe is annotated")
}
func TestCheckAppSec_ObserveAllowsAndRecordsVerdict(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached, "observe mode must not block")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_AllowedRequestPasses(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow","http_status":200}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.NotContains(t, meta, "appsec_verdict", "a clean request records no verdict")
}
func TestCheckAppSec_OffSkipsInspection(t *testing.T) {
// An engine that would ban everything; the mode must keep us away from it.
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecOff, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Empty(t, meta)
}
func TestCheckAppSec_EnforceFailsClosedWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, nil, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code,
"enforce with no configured endpoint must deny rather than pass traffic uninspected")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_ObserveAllowsWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, nil, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_EnforceFailsClosedWhenEngineUnreachable(t *testing.T) {
client, err := appsec.New(appsec.Config{URL: "http://127.0.0.1:1/", APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_InspectsOverlayTraffic(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
// Requests arriving over the WireGuard overlay skip the geo and IP-reputation
// checks, but request content is just as inspectable.
r := appsecRequest()
r = r.WithContext(types.WithOverlayOrigin(r.Context()))
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code, "overlay traffic must still be inspected")
assert.False(t, reached)
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
}
func TestCheckAppSec_UnresolvableClientIPFailsClosed(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
r := appsecRequest()
r.RemoteAddr = "not-an-address"
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code,
"the engine requires a client address; a request we cannot attribute must not pass")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
// The redaction sets are resolved from the domain's schemes at registration, so
// what AppSec withholds cannot drift from what those schemes actually accept.
func TestAddDomain_ResolvesRedactionSetsFromSchemes(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
Schemes: []Scheme{
NewPassword(nil, "svc-1", "acct-1"),
NewHeader(nil, "svc-1", "acct-1", "X-Api-Key"),
},
SessionPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
SessionExpiration: time.Hour,
AppSecMode: restrict.AppSecEnforce,
}))
mw.domainsMux.RLock()
config := mw.domains["svc.example.com"]
mw.domainsMux.RUnlock()
assert.Equal(t, []string{"password"}, config.redactBodyFields)
assert.Equal(t, []string{"X-Api-Key"}, config.redactHeaders)
// r.FormValue merges the query into the form, so a credential passed there
// authenticates and must be redacted alongside the OIDC session token.
assert.Equal(t, []string{"session_token", "password"}, config.redactQueryParams)
}
// countingBudget records reservations so a test can observe when the
// middleware hands them back.
type countingBudget struct {
mu sync.Mutex
total int64
used int64
maxAtOnce int64
}
func (b *countingBudget) Acquire(n int64) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.used+n > b.total {
return false
}
b.used += n
if b.used > b.maxAtOnce {
b.maxAtOnce = b.used
}
return true
}
func (b *countingBudget) Release(n int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.used -= n
}
func (b *countingBudget) inUse() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.used
}
// The buffered body stays alive as r.Body until the backend has read it, so
// Protect must hold the reservation for the whole request and return it only
// once the handler chain has unwound. Releasing inside Inspect would let the
// budget admit buffering that is still resident.
func TestProtect_AppSecBudgetHeldForRequestAndReleasedAfter(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: restrict.AppSecEnforce,
}))
var inHandler int64
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The backend reads the buffered body here, so the reservation must
// still be held at this point.
inHandler = budget.inUse()
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
handler.ServeHTTP(httptest.NewRecorder(), r)
assert.Equal(t, int64(4096), inHandler, "the reservation must be held while the backend reads the body")
assert.Equal(t, int64(0), budget.inUse(), "Protect must release the reservation once the request is served")
}
// A denied request never reaches the backend, but Protect still has to hand the
// reservation back or the pool leaks one cap per blocked request.
func TestProtect_AppSecBudgetReleasedOnDeny(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
rec, _, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, int64(0), budget.inUse(), "a blocked request must not leak its reservation")
}

View File

@@ -39,6 +39,11 @@ func (Header) Type() auth.Method {
return auth.MethodHeader
}
// HeaderName returns the request header this scheme reads its credential from.
func (h Header) HeaderName() string {
return h.headerName
}
// Authenticate checks for the configured header in the request. If absent,
// returns empty (unauthenticated). If present, validates via gRPC.
func (h Header) Authenticate(r *http.Request) (string, string, error) {

View File

@@ -18,6 +18,7 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
@@ -25,6 +26,11 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionCookieNames is the cookie set AppSec redacts before mirroring: the
// proxy's session cookie is a bearer credential for the service, and the
// reverse proxy already strips it before forwarding upstream.
var sessionCookieNames = []string{auth.SessionCookieName}
// errValidationUnavailable indicates that session validation failed due to
// an infrastructure error (e.g. gRPC unavailable), not an invalid token.
var errValidationUnavailable = errors.New("session validation unavailable")
@@ -59,6 +65,14 @@ type DomainConfig struct {
IPRestrictions *restrict.Filter
// Private routes the domain through ValidateTunnelPeer; failure → 403.
Private bool
// AppSecMode enables CrowdSec AppSec request inspection for this domain.
AppSecMode restrict.AppSecMode
// redact* name the credentials this domain's schemes accept, resolved once
// at registration. AppSec replaces their values before mirroring a request,
// matching what the reverse proxy strips before forwarding upstream.
redactBodyFields []string
redactHeaders []string
redactQueryParams []string
}
type validationResult struct {
@@ -82,6 +96,9 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
// appsec is the shared CrowdSec AppSec client, nil when the proxy has no
// AppSec endpoint configured. Set once during startup, before serving.
appsec *appsec.Client
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -99,6 +116,12 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
}
}
// SetAppSec installs the shared CrowdSec AppSec client. Must be called during
// startup, before the middleware serves any request.
func (mw *Middleware) SetAppSec(client *appsec.Client) {
mw.appsec = client
}
// Protect wraps next with per-domain authentication and IP restriction checks.
// Requests whose Host is not registered pass through unchanged.
func (mw *Middleware) Protect(next http.Handler) http.Handler {
@@ -123,6 +146,14 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
// Deferred, not released here: the inspected body stays alive as r.Body
// until the backend has read it, which happens inside next.ServeHTTP.
appSecAllowed, releaseAppSec := mw.checkAppSec(w, r, config)
defer releaseAppSec()
if !appSecAllowed {
return
}
// Private services bypass operator schemes and gate on tunnel peer.
if config.Private {
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
@@ -262,6 +293,134 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
return false
}
// checkAppSec mirrors the request to the CrowdSec AppSec engine when the domain
// enables inspection. Returns false when the request was blocked and a response
// has been written.
//
// The returned release frees the body-buffering budget the inspection reserved
// and is never nil. It must run only after the request has been served, since
// the buffered body stays alive as r.Body for the backend to read.
//
// Every non-allow remediation blocks with 403, captcha included: the proxy has
// no challenge flow to serve. The distinct verdict is still recorded so the
// access log shows which remediation the engine actually chose.
//
// Unlike the geo and IP-reputation checks, this runs for overlay traffic too:
// AppSec inspects request content, which is just as meaningful when the caller
// reached the proxy through the WireGuard tunnel.
func (mw *Middleware) checkAppSec(w http.ResponseWriter, r *http.Request, config DomainConfig) (bool, func()) {
if !config.AppSecMode.Enabled() {
return true, func() {}
}
verdict, release := mw.inspectAppSec(r, config)
if verdict == restrict.Allow {
return true, release
}
observe := config.AppSecMode == restrict.AppSecObserve
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_verdict", verdict.String())
if observe {
cd.SetMetadata("appsec_mode", "observe")
}
}
if observe {
mw.logger.Debugf("AppSec observe: would block %s for %s (%s)", r.RemoteAddr, r.Host, verdict)
return true, release
}
mw.markDenied(r, verdict.String())
mw.logger.Debugf("AppSec: %s for %s %s", verdict, r.Host, r.RemoteAddr)
http.Error(w, "Forbidden", http.StatusForbidden)
return false, release
}
// inspectAppSec runs the AppSec call and returns its verdict. Failures come
// back as DenyAppSecUnavailable regardless of mode so observe mode still
// records that inspection did not happen; the caller decides what blocks. The
// returned release is never nil.
func (mw *Middleware) inspectAppSec(r *http.Request, config DomainConfig) (restrict.Verdict, func()) {
// Mode requested but the proxy has no AppSec endpoint configured. Management
// gates this on the cluster capability; a stale mapping can still arrive.
if mw.appsec == nil {
mw.logger.Debugf("AppSec mode %q requested for %s but no AppSec endpoint is configured", config.AppSecMode, r.Host)
return restrict.DenyAppSecUnavailable, func() {}
}
clientIP := mw.resolveClientIP(r)
if !clientIP.IsValid() {
// The engine requires a client address, and a request whose source we
// cannot establish is exactly the kind we must not wave through.
mw.logger.Debugf("AppSec: cannot resolve client address for %q", r.RemoteAddr)
return restrict.DenyAppSecUnavailable, func() {}
}
req := appsec.Request{
HTTP: r,
ClientIP: clientIP,
RedactBodyFields: config.redactBodyFields,
RedactHeaders: config.redactHeaders,
RedactCookies: sessionCookieNames,
RedactQueryParams: config.redactQueryParams,
}
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
req.TransactionID = cd.GetRequestID()
}
result, err := mw.appsec.Inspect(r.Context(), req)
if err != nil {
mw.logger.Debugf("AppSec inspection failed for %s: %v", r.Host, err)
}
// Record when the body went uninspected: headers and URI were still
// checked, but an operator reading the log should not read a clean verdict
// as "the payload was examined". Oversize is reachable by padding, so its
// absence from the log would hide a deliberate opt-out.
if result.BodyBypass != "" {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_body_bypass", result.BodyBypass)
}
}
return result.Verdict, result.Release
}
// credentialFormFields lists the login form fields whose values are redacted
// from the mirrored body, so a password or PIN submitted to the proxy's own
// login form never reaches the Security Engine.
func credentialFormFields(schemes []Scheme) []string {
var fields []string
for _, s := range schemes {
switch s.Type() {
case auth.MethodPassword:
fields = append(fields, passwordFormId)
case auth.MethodPIN:
fields = append(fields, pinFormId)
}
}
return fields
}
// credentialHeaders lists the request headers whose values are redacted from
// the mirrored request. A header-auth scheme carries a session token the proxy
// validates and never forwards upstream, so the engine must not see it either.
func credentialHeaders(schemes []Scheme) []string {
var names []string
for _, s := range schemes {
// Structural, not a concrete Header assertion: if the scheme is ever
// registered as a pointer, a type assertion would quietly stop matching
// and the header would start reaching the engine again.
named, ok := s.(interface{ HeaderName() string })
if !ok {
continue
}
if name := named.HeaderName(); name != "" {
names = append(names, name)
}
}
return names
}
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
@@ -281,12 +440,18 @@ func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
return addr.Unmap()
}
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
// markDenied records the deny reason on the captured data so the access log
// attributes the response to the proxy rather than the backend.
func (mw *Middleware) markDenied(r *http.Request, reason string) {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(reason)
}
}
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
mw.markDenied(r, reason)
mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr)
}
@@ -637,45 +802,61 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
case auth.MethodPassword:
return r.FormValue("password") != ""
case auth.MethodOIDC:
return r.URL.Query().Get("session_token") != ""
return r.URL.Query().Get(sessionTokenParam) != ""
}
return false
}
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
return nil
// DomainSettings is the per-domain configuration AddDomain applies.
type DomainSettings struct {
Schemes []Scheme
// SessionPublicKey is the base64-encoded ed25519 key used to verify session
// cookies. Required when Schemes is non-empty.
SessionPublicKey string
SessionExpiration time.Duration
AccountID types.AccountID
ServiceID types.ServiceID
IPRestrictions *restrict.Filter
// Private forces ValidateTunnelPeer enforcement (403 on failure) regardless
// of the schemes list.
Private bool
AppSecMode restrict.AppSecMode
}
// AddDomain registers authentication schemes for the given domain. With schemes
// a valid session public key is required.
func (mw *Middleware) AddDomain(domain string, settings DomainSettings) error {
credentialFields := credentialFormFields(settings.Schemes)
config := DomainConfig{
AccountID: settings.AccountID,
ServiceID: settings.ServiceID,
IPRestrictions: settings.IPRestrictions,
Private: settings.Private,
AppSecMode: settings.AppSecMode,
redactBodyFields: credentialFields,
redactHeaders: credentialHeaders(settings.Schemes),
// A credential can arrive in the query too: r.FormValue merges the URL
// query into the form, so "?password=..." authenticates just as a form
// post does and must not be mirrored in the clear either.
redactQueryParams: append([]string{sessionTokenParam}, credentialFields...),
}
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
if len(settings.Schemes) > 0 {
pubKeyBytes, err := base64.StdEncoding.DecodeString(settings.SessionPublicKey)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
config.Schemes = settings.Schemes
config.SessionPublicKey = pubKeyBytes
config.SessionExpiration = settings.SessionExpiration
}
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
Schemes: schemes,
SessionPublicKey: pubKeyBytes,
SessionExpiration: expiration,
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
mw.domains[domain] = config
return nil
}
@@ -730,10 +911,10 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
// parameter removed so it doesn't linger in the browser's address bar or history.
func stripSessionTokenParam(u *url.URL) string {
q := u.Query()
if !q.Has("session_token") {
if !q.Has(sessionTokenParam) {
return u.RequestURI()
}
q.Del("session_token")
q.Del(sessionTokenParam)
clean := *u
clean.RawQuery = q.Encode()
return clean.RequestURI()

View File

@@ -64,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour})
require.NoError(t, err)
mw.domainsMux.RLock()
@@ -81,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionExpiration: time.Hour})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -95,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "not-valid-base64!!!", SessionExpiration: time.Hour})
require.Error(t, err)
assert.Contains(t, err.Error(), "decode session public key")
@@ -110,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: shortKey, SessionExpiration: time.Hour})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -123,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour})
require.NoError(t, err, "domains with no auth schemes should not require a key")
mw.domainsMux.RLock()
@@ -139,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp2.PublicKey, SessionExpiration: 2 * time.Hour}))
mw.domainsMux.RLock()
config := mw.domains["example.com"]
@@ -156,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
mw.RemoveDomain("example.com")
@@ -180,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -197,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -218,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -239,7 +239,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
@@ -272,7 +272,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
groups := []string{"engineering", "sre"}
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
@@ -337,7 +337,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
// Private service: no operator schemes — auth gates solely on the tunnel peer.
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
@@ -377,7 +377,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
}}
mw := NewMiddleware(log.StandardLogger(), validator, nil)
kp := generateTestKeyPair(t)
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
@@ -405,7 +405,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
// Sign a token that expired 1 second ago.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
@@ -431,7 +431,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
// Token signed for a different domain audience.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -458,7 +458,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
kp2 := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
// Token signed with a different private key.
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -495,7 +495,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -548,7 +548,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -584,7 +584,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{pinScheme, passwordScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -614,7 +614,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
return "invalid-jwt-token", "", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -638,7 +638,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
key := base64.StdEncoding.EncodeToString(randomBytes)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
err = mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: key, SessionExpiration: time.Hour})
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
}
@@ -647,10 +647,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
// Attempt to overwrite with an invalid key.
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "bad", SessionExpiration: time.Hour})
require.Error(t, err)
// The original valid config should still be intact.
@@ -674,7 +674,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -701,7 +701,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -728,7 +728,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -815,8 +815,7 @@ func TestWasCredentialSubmitted(t *testing.T) {
func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})})
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -851,8 +850,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
// trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr.
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}})})
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -892,8 +890,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
// Geo is nil, country restrictions are configured: must deny (fail-close).
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}})})
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -916,11 +913,10 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
}), false)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
})})
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -953,8 +949,7 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})})
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -982,7 +977,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
return "", oidcURL, nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1011,7 +1006,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{oidcScheme, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1055,7 +1050,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
var backendCalled bool
capturedData := proxy.NewCapturedData("")
@@ -1098,7 +1093,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
// Also add a PIN scheme so we can verify fallthrough behavior.
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
handler := mw.Protect(newPassthroughHandler())
@@ -1118,7 +1113,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
return &proto.AuthenticateResponse{Success: false}, nil
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -1141,7 +1136,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
return nil, errors.New("gRPC unavailable")
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
handler := mw.Protect(newPassthroughHandler())
@@ -1158,7 +1153,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1218,7 +1213,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
// Single Header scheme (as if one entry existed), but the mock checks both values.
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1276,7 +1271,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1300,7 +1295,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1320,7 +1315,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1350,7 +1345,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())
@@ -1385,7 +1380,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
handler := mw.Protect(newPassthroughHandler())

View File

@@ -13,6 +13,10 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionTokenParam is the query parameter the management server uses to hand
// the minted session token back to the proxy after an OIDC login.
const sessionTokenParam = "session_token"
type urlGenerator interface {
GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error)
}
@@ -43,7 +47,7 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
// Check for the session_token query param (from OIDC redirects).
// The management server passes the token in the URL because it cannot set
// cookies for the proxy's domain (cookies are domain-scoped per RFC 6265).
if token := r.URL.Query().Get("session_token"); token != "" {
if token := r.URL.Query().Get(sessionTokenParam); token != "" {
return token, "", nil
}

View File

@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
t.Helper()
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
require.NoError(t, mw.AddDomain("svc.example", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1"}))
return mw
}
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
require.NoError(t, mw.AddDomain("svc-a.example", DomainSettings{AccountID: "acct-a", ServiceID: "svc-a"}))
require.NoError(t, mw.AddDomain("svc-b.example", DomainSettings{AccountID: "acct-b", ServiceID: "svc-b"}))
// The fast-path requires the inbound-listener marker on the context.
// The peerstore lookup itself is account-agnostic at this level
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
mw := NewMiddleware(log.New(), nil, nil)
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
},
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

View File

@@ -21,6 +21,8 @@ import (
"strconv"
"strings"
"sync"
"github.com/netbirdio/netbird/proxy/internal/netutil"
)
// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a
@@ -34,7 +36,6 @@ const MaxRoutingScanBytes int64 = 32 << 20
// metadata key by the chain when a request body is not surfaced.
const (
BypassUpgradeHeader = "upgrade_header"
BypassConnectionUpgrd = "connection_upgrade"
BypassContentType = "content_type_not_allowed"
BypassBudget = "capture_budget_exhausted"
BypassNoConfig = "no_capture_config"
@@ -125,12 +126,13 @@ func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, trunca
if cfg.MaxRequestBytes <= 0 {
return nil, false, 0, BypassCapZero, release, nil
}
if r.Header.Get("Upgrade") != "" {
// The predicate has to be the forwarder's own: a looser one (either header
// on its own) skips capture for requests the forwarder still delivers to
// the upstream with their body intact, which hides them from every
// deny-capable middleware in the chain.
if netutil.IsUpgradeRequest(r.Header) {
return nil, false, 0, BypassUpgradeHeader, release, nil
}
if strings.EqualFold(r.Header.Get("Connection"), "upgrade") {
return nil, false, 0, BypassConnectionUpgrd, release, nil
}
if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) {
return nil, false, 0, BypassContentType, release, nil
}

View File

@@ -0,0 +1,23 @@
package netutil
import (
"net/http"
"golang.org/x/net/http/httpguts"
)
// IsUpgradeRequest reports whether r is a protocol-upgrade request, using the
// same predicate httputil.ReverseProxy applies when it decides to hand the
// connection over instead of proxying normally.
//
// Matching the forwarder exactly matters for anything that inspects a request
// before it is proxied: a looser test (an Upgrade header on its own, say) marks
// a request as an upgrade and skips inspection, while the forwarder still
// delivers it to the backend as an ordinary request with its body intact. That
// gap is a body-inspection bypass reachable by adding one header.
func IsUpgradeRequest(h http.Header) bool {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return false
}
return h.Get("Upgrade") != ""
}

View File

@@ -50,6 +50,59 @@ const (
CrowdSecObserve CrowdSecMode = "observe"
)
// AllowMatch controls how the configured allowlists (CIDR, country) combine.
// Blocklists are always a separate hard-deny gate and are unaffected by it.
type AllowMatch string
const (
// AllowMatchAll requires the address to match every configured allowlist
// (AND). This is the default and preserves the historical behavior.
AllowMatchAll AllowMatch = "all"
// AllowMatchAny requires the address to match at least one configured
// allowlist (OR), e.g. "allowed country OR allowed CIDR".
AllowMatchAny AllowMatch = "any"
)
// normalizeAllowMatch maps unknown or empty values to the restrictive default
// (AllowMatchAll) so an unrecognized mode never loosens access.
func normalizeAllowMatch(m AllowMatch) AllowMatch {
if m == AllowMatchAny {
return AllowMatchAny
}
return AllowMatchAll
}
// AppSecMode is the per-service CrowdSec AppSec (WAF) enforcement mode.
type AppSecMode string
const (
// AppSecOff disables request inspection.
AppSecOff AppSecMode = ""
// AppSecEnforce blocks requests the engine flags, and fails closed when the
// engine is unreachable.
AppSecEnforce AppSecMode = "enforce"
// AppSecObserve records the verdict without blocking.
AppSecObserve AppSecMode = "observe"
)
// ParseAppSecMode maps a wire value to an AppSecMode. Unrecognized values map
// to AppSecOff so a typo never turns inspection into an unintended block.
func ParseAppSecMode(s string) AppSecMode {
switch AppSecMode(s) {
case AppSecEnforce:
return AppSecEnforce
case AppSecObserve:
return AppSecObserve
default:
return AppSecOff
}
}
// Enabled reports whether the mode asks for request inspection.
func (m AppSecMode) Enabled() bool {
return m == AppSecEnforce || m == AppSecObserve
}
// Filter evaluates IP restrictions. CIDR checks are performed first
// (cheap), followed by country lookups (more expensive) only when needed.
type Filter struct {
@@ -59,6 +112,9 @@ type Filter struct {
BlockedCountries []string
CrowdSec CrowdSecChecker
CrowdSecMode CrowdSecMode
// AllowMatch controls how the allowlists combine (AND vs OR). Empty means
// AllowMatchAll.
AllowMatch AllowMatch
}
// FilterConfig holds the raw configuration for building a Filter.
@@ -69,6 +125,7 @@ type FilterConfig struct {
BlockedCountries []string
CrowdSec CrowdSecChecker
CrowdSecMode CrowdSecMode
AllowMatch AllowMatch
Logger *log.Entry
}
@@ -89,6 +146,7 @@ func ParseFilter(cfg FilterConfig) *Filter {
f := &Filter{
AllowedCountries: normalizeCountryCodes(cfg.AllowedCountries),
BlockedCountries: normalizeCountryCodes(cfg.BlockedCountries),
AllowMatch: normalizeAllowMatch(cfg.AllowMatch),
}
if hasCS {
f.CrowdSec = cfg.CrowdSec
@@ -146,6 +204,13 @@ const (
// DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not
// completed its initial sync.
DenyCrowdSecUnavailable
// DenyAppSecBan indicates a CrowdSec AppSec "ban" remediation.
DenyAppSecBan
// DenyAppSecCaptcha indicates a CrowdSec AppSec "captcha" remediation.
DenyAppSecCaptcha
// DenyAppSecUnavailable indicates enforce mode but the AppSec engine could
// not produce a verdict (unreachable, timed out, or it rejected the call).
DenyAppSecUnavailable
)
// String returns the deny reason string matching the HTTP auth mechanism names.
@@ -167,6 +232,12 @@ func (v Verdict) String() string {
return "crowdsec_throttle"
case DenyCrowdSecUnavailable:
return "crowdsec_unavailable"
case DenyAppSecBan:
return "appsec_ban"
case DenyAppSecCaptcha:
return "appsec_captcha"
case DenyAppSecUnavailable:
return "appsec_unavailable"
default:
return "unknown"
}
@@ -182,6 +253,16 @@ func (v Verdict) IsCrowdSec() bool {
}
}
// IsAppSec returns true when the verdict originates from an AppSec inspection.
func (v Verdict) IsAppSec() bool {
switch v {
case DenyAppSecBan, DenyAppSecCaptcha, DenyAppSecUnavailable:
return true
default:
return false
}
}
// IsObserveOnly returns true when v is a CrowdSec verdict and the filter is in
// observe mode. Callers should log the verdict but not block the request.
func (f *Filter) IsObserveOnly(v Verdict) bool {
@@ -216,6 +297,10 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
// IPv4 CIDR rules match regardless of how the address was received.
addr = addr.Unmap()
if f.AllowMatch == AllowMatchAny {
return f.checkAny(addr, geo)
}
if v := f.checkCIDR(addr); v != Allow {
return v
}
@@ -225,6 +310,68 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
return f.checkCrowdSec(addr)
}
// checkAny evaluates the filter with OR semantics across allowlists: the
// address is admitted if it matches any configured allowlist (CIDR or country).
// Blocklists remain a hard-deny gate evaluated first and are independent of the
// allow-combine mode, so a blocklist match (or unverifiable country block) still
// denies. CrowdSec runs last, as in the default path.
//
// The country is resolved at most once and shared by both the blocklist and the
// allowlist, matching what the all-mode path does. Splitting the two checks into
// separate helpers cost a second geo lookup per connection whenever both country
// lists were configured.
func (f *Filter) checkAny(addr netip.Addr, geo GeoResolver) Verdict {
for _, prefix := range f.BlockedCIDRs {
if prefix.Contains(addr) {
return DenyCIDR
}
}
cidrActive := len(f.AllowedCIDRs) > 0
cidrAllowed := false
if cidrActive {
for _, prefix := range f.AllowedCIDRs {
if prefix.Contains(addr) {
cidrAllowed = true
break
}
}
}
countryActive := len(f.AllowedCountries) > 0
// The blocklist is a hard gate, so it needs the country even when a CIDR
// allowlist already admitted the address. The allowlist needs it only when
// the CIDR list did not admit it, which is why a matching allowed CIDR
// still skips the lookup when no country blocklist is configured.
needCountry := len(f.BlockedCountries) > 0 || (countryActive && !cidrAllowed)
country := ""
if needCountry {
if geo == nil || !geo.Available() {
return DenyGeoUnavailable
}
country = geo.LookupAddr(addr).CountryCode
}
if country != "" && slices.Contains(f.BlockedCountries, country) {
return DenyCountry
}
allowed := (!cidrActive && !countryActive) ||
cidrAllowed ||
(countryActive && country != "" && slices.Contains(f.AllowedCountries, country))
if !allowed {
// Both allowlists missing is reported against the CIDR list, the one
// checked first, so the reason stays stable for existing access logs.
if cidrActive {
return DenyCIDR
}
return DenyCountry
}
return f.checkCrowdSec(addr)
}
func (f *Filter) checkCIDR(addr netip.Addr) Verdict {
if len(f.AllowedCIDRs) > 0 {
allowed := false

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/geolocation"
)
@@ -150,6 +151,187 @@ func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check")
}
// TestFilter_Check_CrossCategoryAllowlistsAreAND documents the current
// behavior: when both a CIDR allowlist and a country allowlist are set, a
// request must satisfy BOTH to be allowed (AND across categories). There is no
// way today to express "allow if in allowed country OR in allowed CIDR", e.g.
// "allow all US traffic plus our office IP abroad". This is the gap an
// any/all allow-combine mode would close; the cases marked "GAP" are the ones
// that would flip to Allow under an "any" mode.
func TestFilter_Check_CrossCategoryAllowlistsAreAND(t *testing.T) {
officeAbroad := "203.0.113.7" // in allowed CIDR, but country not in allowlist
usOutsideOffice := "1.1.1.1" // allowed country, but not in allowed CIDR
usOffice := "203.0.113.8" // both
neither := "198.51.100.1" // neither
geo := newMockGeo(map[string]string{
officeAbroad: "DE",
usOutsideOffice: "US",
usOffice: "US",
neither: "CN",
})
f := ParseFilter(FilterConfig{
AllowedCIDRs: []string{"203.0.113.0/24"},
AllowedCountries: []string{"US"},
})
assert.Equal(t, Allow, f.Check(netip.MustParseAddr(usOffice), geo), "in allowed CIDR and allowed country")
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr(officeAbroad), geo), "GAP: in allowed CIDR but country not allowed; any-mode should Allow")
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(usOutsideOffice), geo), "GAP: allowed country but not in allowed CIDR; any-mode should Allow")
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(neither), geo), "neither: denied under both modes")
}
// TestFilter_Check_CrossCategoryBlockAndAllow locks the current (all/AND)
// cross-category semantics that the evaluator must preserve: a blocklist match
// in any category denies regardless of allowlists, and blocklists across
// categories are effectively OR (a match in either denies).
func TestFilter_Check_CrossCategoryBlockAndAllow(t *testing.T) {
geo := newMockGeo(map[string]string{
"1.1.1.1": "US",
"10.1.2.3": "US",
"2.2.2.2": "CN",
"3.3.3.3": "US",
})
t.Run("country allowlist with CIDR blocklist", func(t *testing.T) {
f := ParseFilter(FilterConfig{
AllowedCountries: []string{"US"},
BlockedCIDRs: []string{"10.1.0.0/16"},
})
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US and not in blocked CIDR")
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "US but in blocked CIDR, block wins")
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "not in allowed country")
})
t.Run("blocklists across categories are OR", func(t *testing.T) {
f := ParseFilter(FilterConfig{
BlockedCIDRs: []string{"10.1.0.0/16"},
BlockedCountries: []string{"CN"},
})
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "in blocked CIDR")
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "in blocked country")
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "in neither blocklist")
})
}
// TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN documents a trap
// with all/AND mode: pairing an allowed CIDR (a private LAN) with an allowed
// country denies the LAN source, because a private IP has no country in the
// geo DB and an active country allowlist denies unknown countries. Under an
// "any" mode the CIDR match alone would admit it. This is the strongest reason
// allow-CIDR + allow-country usually wants OR, not AND.
func TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN(t *testing.T) {
geo := newMockGeo(map[string]string{}) // no entries: every lookup is unknown country
f := ParseFilter(FilterConfig{
AllowedCIDRs: []string{"192.168.50.0/24"},
AllowedCountries: []string{"US"},
})
got := f.Check(netip.MustParseAddr("192.168.50.5"), geo)
assert.Equal(t, DenyCountry, got, "GAP: LAN source in allowed CIDR is denied by the country allowlist; any-mode should Allow")
}
func TestFilter_Check_AllowMatchAny(t *testing.T) {
bannedIP := "203.0.113.9"
geo := newMockGeo(map[string]string{
"1.1.1.1": "US", // allowed country, outside allowed CIDR
"203.0.113.7": "DE", // allowed CIDR, non-allowed country
"203.0.113.8": "US", // both
bannedIP: "US", // allowed CIDR, but CrowdSec-banned
"198.51.100.1": "CN", // neither
"2.2.2.2": "CN", // blocked country, but in allowed CIDR
})
tests := []struct {
name string
config FilterConfig
addr string
geo GeoResolver
want Verdict
}{
{
name: "in allowed CIDR only",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "203.0.113.7", geo: geo, want: Allow,
},
{
name: "in allowed country only",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "1.1.1.1", geo: geo, want: Allow,
},
{
name: "in both",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "203.0.113.8", geo: geo, want: Allow,
},
{
name: "in neither",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "198.51.100.1", geo: geo, want: DenyCIDR,
},
{
name: "geoless LAN admitted via CIDR (the #597 trap, fixed)",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"192.168.50.0/24"}, AllowedCountries: []string{"US"}},
addr: "192.168.50.5", geo: newMockGeo(map[string]string{}), want: Allow,
},
{
name: "CIDR match short-circuits geo when geo unavailable",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "203.0.113.7", geo: &unavailableGeo{}, want: Allow,
},
{
name: "geo unavailable fails closed when CIDR does not match",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
addr: "1.1.1.1", geo: &unavailableGeo{}, want: DenyGeoUnavailable,
},
{
name: "block gate wins over allowed CIDR (blocked country)",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"0.0.0.0/0"}, BlockedCountries: []string{"CN"}},
addr: "2.2.2.2", geo: geo, want: DenyCountry,
},
{
name: "block gate wins over allowed country (blocked CIDR)",
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCountries: []string{"US"}, BlockedCIDRs: []string{"203.0.113.0/24"}},
addr: "203.0.113.8", geo: geo, want: DenyCIDR,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := ParseFilter(tc.config)
assert.Equal(t, tc.want, f.Check(netip.MustParseAddr(tc.addr), tc.geo))
})
}
}
func TestFilter_Check_AllowMatchAny_CrowdSecStillRuns(t *testing.T) {
bannedIP := "203.0.113.9"
cs := &mockCrowdSec{decisions: map[string]*CrowdSecDecision{bannedIP: {Type: DecisionBan}}, ready: true}
geo := newMockGeo(map[string]string{bannedIP: "US", "203.0.113.7": "US"})
f := ParseFilter(FilterConfig{
AllowMatch: AllowMatchAny,
AllowedCIDRs: []string{"203.0.113.0/24"},
CrowdSec: cs,
CrowdSecMode: CrowdSecEnforce,
})
assert.Equal(t, DenyCrowdSecBan, f.Check(netip.MustParseAddr(bannedIP), geo), "CrowdSec ban denies even when allowlist admits")
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "clean IP in allowed CIDR is allowed")
}
func TestFilter_Check_UnknownAllowMatchDefaultsToAll(t *testing.T) {
// An unrecognized allow-combine mode must fall back to the restrictive
// AND default, never loosen access.
geo := newMockGeo(map[string]string{"203.0.113.7": "DE"})
f := ParseFilter(FilterConfig{
AllowMatch: AllowMatch("bogus"),
AllowedCIDRs: []string{"203.0.113.0/24"},
AllowedCountries: []string{"US"},
})
assert.Equal(t, AllowMatchAll, f.AllowMatch, "unknown mode normalizes to all")
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "AND semantics: in CIDR but wrong country denied")
}
func TestParseFilter_Empty(t *testing.T) {
f := ParseFilter(FilterConfig{})
assert.Nil(t, f)
@@ -552,3 +734,81 @@ func TestFilter_HasRestrictions_CrowdSec(t *testing.T) {
f2 := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecEnforce})
assert.True(t, f2.HasRestrictions())
}
// countingGeo records how many times an address was resolved.
type countingGeo struct {
countries map[string]string
lookups int
}
func (c *countingGeo) LookupAddr(addr netip.Addr) geolocation.Result {
c.lookups++
return geolocation.Result{CountryCode: c.countries[addr.String()]}
}
func (c *countingGeo) Available() bool { return true }
// The geo lookup is the expensive part of the check and runs per connection, so
// "any" mode must resolve the country once and share it between the blocklist
// and the allowlist, the way "all" mode does.
func TestCheck_AnyResolvesCountryOnce(t *testing.T) {
tests := []struct {
name string
ip string
want Verdict
wantLookups int
}{
{"blocked and allowed lists both active", "203.0.113.1", Allow, 1},
{"blocked country denies", "198.51.100.1", DenyCountry, 1},
{"neither allowlist matches", "192.0.2.1", DenyCIDR, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
geo := &countingGeo{countries: map[string]string{
"203.0.113.1": "US",
"198.51.100.1": "CN",
"192.0.2.1": "FR",
}}
f := ParseFilter(FilterConfig{
AllowedCIDRs: []string{"10.0.0.0/8"},
AllowedCountries: []string{"US"},
BlockedCountries: []string{"CN"},
AllowMatch: AllowMatchAny,
})
require.NotNil(t, f)
assert.Equal(t, tt.want, f.Check(netip.MustParseAddr(tt.ip), geo))
assert.Equal(t, tt.wantLookups, geo.lookups, "the country must be resolved at most once per check")
})
}
}
// A matching allowed CIDR short-circuits the allowlist, so with no country
// blocklist configured there is nothing left to resolve.
func TestCheck_AnySkipsLookupWhenCIDRAdmits(t *testing.T) {
geo := &countingGeo{countries: map[string]string{"10.1.2.3": "US"}}
f := ParseFilter(FilterConfig{
AllowedCIDRs: []string{"10.0.0.0/8"},
AllowedCountries: []string{"DE"},
AllowMatch: AllowMatchAny,
})
require.NotNil(t, f)
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), geo))
assert.Zero(t, geo.lookups, "an admitted CIDR needs no geo lookup")
}
// The blocklist is a hard gate, so it is consulted even when a CIDR allowlist
// already admitted the address.
func TestCheck_AnyBlocklistOutranksAllowedCIDR(t *testing.T) {
geo := &countingGeo{countries: map[string]string{"10.1.2.3": "CN"}}
f := ParseFilter(FilterConfig{
AllowedCIDRs: []string{"10.0.0.0/8"},
BlockedCountries: []string{"CN"},
AllowMatch: AllowMatchAny,
})
require.NotNil(t, f)
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo))
assert.Equal(t, 1, geo.lookups)
}

View File

@@ -126,6 +126,23 @@ type Config struct {
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
// CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint. Empty disables
// HTTP request inspection.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call. Zero falls
// back to the internal default.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to AppSec.
// Zero falls back to the internal default; negative forwards no body.
CrowdSecAppSecMaxBodyBytes int64
// CrowdSecAppSecMaxConcurrent bounds AppSec inspections in flight toward
// the engine. Zero falls back to the internal default; negative removes
// the bound.
CrowdSecAppSecMaxConcurrent int
// MiddlewareCaptureBudgetBytes bounds the total request-body buffering in
// flight across the proxy, shared by AppSec inspection and the
// agent-network capture. Zero falls back to the internal default.
MiddlewareCaptureBudgetBytes int64
}
// New builds a Server from cfg without performing any I/O. No goroutines
@@ -135,42 +152,47 @@ type Config struct {
// directly) byte-for-byte equivalent.
func New(ctx context.Context, cfg Config) *Server {
return &Server{
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
CrowdSecAppSecURL: cfg.CrowdSecAppSecURL,
CrowdSecAppSecTimeout: cfg.CrowdSecAppSecTimeout,
CrowdSecAppSecMaxBodyBytes: cfg.CrowdSecAppSecMaxBodyBytes,
CrowdSecAppSecMaxConcurrent: cfg.CrowdSecAppSecMaxConcurrent,
MiddlewareCaptureBudgetBytes: cfg.MiddlewareCaptureBudgetBytes,
}
}

45
proxy/lifecycle_test.go Normal file
View File

@@ -0,0 +1,45 @@
package proxy
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// New maps Config onto Server field by field, and a field left out of that
// literal still compiles: the knob is simply parsed and then dropped, so an
// operator setting it sees the default with no error anywhere. These assertions
// are the only thing standing between a new setting and that silent no-op.
func TestNew_ForwardsOperatorTuning(t *testing.T) {
cfg := Config{
ManagementAddress: "http://localhost:8080",
ProxyToken: "token",
CrowdSecAPIURL: "http://crowdsec:8080/",
CrowdSecAPIKey: "key",
CrowdSecAppSecURL: "http://crowdsec:7422/",
CrowdSecAppSecTimeout: 321 * time.Millisecond,
CrowdSecAppSecMaxBodyBytes: 4321,
CrowdSecAppSecMaxConcurrent: 17,
MiddlewareCaptureBudgetBytes: 5 << 20,
MaxDialTimeout: 7 * time.Second,
MaxSessionIdleTimeout: 11 * time.Second,
GeoDataDir: "/var/lib/geo",
}
srv := New(context.Background(), cfg)
require.NotNil(t, srv)
assert.Equal(t, cfg.CrowdSecAPIURL, srv.CrowdSecAPIURL)
assert.Equal(t, cfg.CrowdSecAPIKey, srv.CrowdSecAPIKey)
assert.Equal(t, cfg.CrowdSecAppSecURL, srv.CrowdSecAppSecURL)
assert.Equal(t, cfg.CrowdSecAppSecTimeout, srv.CrowdSecAppSecTimeout)
assert.Equal(t, cfg.CrowdSecAppSecMaxBodyBytes, srv.CrowdSecAppSecMaxBodyBytes)
assert.Equal(t, cfg.CrowdSecAppSecMaxConcurrent, srv.CrowdSecAppSecMaxConcurrent)
assert.Equal(t, cfg.MiddlewareCaptureBudgetBytes, srv.MiddlewareCaptureBudgetBytes)
assert.Equal(t, cfg.MaxDialTimeout, srv.MaxDialTimeout)
assert.Equal(t, cfg.MaxSessionIdleTimeout, srv.MaxSessionIdleTimeout)
assert.Equal(t, cfg.GeoDataDir, srv.GeoDataDir)
}

View File

@@ -240,6 +240,10 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *testProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}
@@ -562,16 +566,11 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
addMappingCalls.Add(1)
// Apply to real auth middleware (idempotent)
err := authMw.AddDomain(
mapping.GetDomain(),
nil,
"",
0,
proxytypes.AccountID(mapping.GetAccountId()),
proxytypes.ServiceID(mapping.GetId()),
nil,
mapping.GetPrivate(),
)
err := authMw.AddDomain(mapping.GetDomain(), auth.DomainSettings{
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
ServiceID: proxytypes.ServiceID(mapping.GetId()),
Private: mapping.GetPrivate(),
})
require.NoError(t, err)
// Apply to real proxy (idempotent)

View File

@@ -45,6 +45,7 @@ import (
"github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
"github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/auth"
"github.com/netbirdio/netbird/proxy/internal/certwatch"
"github.com/netbirdio/netbird/proxy/internal/conntrack"
@@ -126,6 +127,10 @@ type Server struct {
crowdsecMu sync.Mutex
crowdsecServices map[types.ServiceID]bool
// appsecClient is the shared CrowdSec AppSec client, nil when no AppSec
// endpoint is configured. Stateless, so it needs no per-service lifecycle.
appsecClient *appsec.Client
// routerReady is closed once mainRouter is fully initialized.
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
@@ -238,6 +243,20 @@ type Server struct {
CrowdSecAPIURL string
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint, e.g.
// http://127.0.0.1:7422/. Empty disables request inspection. Requires
// CrowdSecAPIKey, which the AppSec component validates against LAPI.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call.
// Zero means appsec.DefaultTimeout.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to the AppSec
// engine. Zero means appsec.DefaultMaxBodyBytes; negative disables body
// forwarding, leaving header and URI inspection.
CrowdSecAppSecMaxBodyBytes int64
// CrowdSecAppSecMaxConcurrent bounds AppSec inspections in flight. Zero
// means appsec.DefaultMaxConcurrent; negative removes the bound.
CrowdSecAppSecMaxConcurrent int
// MaxSessionIdleTimeout caps the per-service session idle timeout.
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
@@ -380,6 +399,13 @@ func (s *Server) Start(ctx context.Context) error {
s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger))
s.crowdsecServices = make(map[types.ServiceID]bool)
// Must precede the mapping worker: the worker opens the management stream
// and reports proxyCapabilities, which reads appsecClient. Building it
// afterwards would both race the read and, when the worker won, advertise
// the proxy as AppSec-incapable for the lifetime of that stream.
if err := s.initAppSec(); err != nil {
return err
}
go s.newManagementMappingWorker(runCtx, s.mgmtClient)
@@ -407,6 +433,7 @@ func (s *Server) Start(ctx context.Context) error {
}()
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
s.auth.SetAppSec(s.appsecClient)
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
s.startDebugEndpoint()
@@ -1270,6 +1297,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
supportsCrowdSec := s.crowdsecRegistry.Available()
supportsAppSec := s.appsecClient != nil
privateCapability := s.Private
// Always true: this build enforces ProxyMapping.private via the auth middleware.
supportsPrivateService := true
@@ -1277,6 +1305,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
SupportsCustomPorts: &s.SupportsCustomPorts,
RequireSubdomain: &s.RequireSubdomain,
SupportsCrowdsec: &supportsCrowdSec,
SupportsAppsec: &supportsAppSec,
Private: &privateCapability,
SupportsPrivateService: &supportsPrivateService,
}
@@ -1900,10 +1929,72 @@ func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter
BlockedCountries: r.GetBlockedCountries(),
CrowdSec: checker,
CrowdSecMode: csMode,
AllowMatch: restrict.AllowMatch(r.GetAllowMatch()),
Logger: log.NewEntry(s.Logger),
})
}
// initAppSec builds the shared AppSec client when an endpoint is configured.
// A configured-but-invalid endpoint is a startup error rather than a silent
// downgrade: services asking for enforce would otherwise fail closed on every
// request with no indication why.
//
// Runs before the management stream opens so the reported capability is stable;
// the auth middleware picks the client up separately once it exists.
func (s *Server) initAppSec() error {
if s.CrowdSecAppSecURL == "" {
return nil
}
if s.CrowdSecAPIKey == "" {
return errors.New("crowdsec appsec url is set but the crowdsec api key is empty")
}
// Share the middleware capture budget rather than opening a second pool:
// AppSec buffers before authentication, so its ceiling has to count against
// the same proxy-wide allowance the body tap draws from.
var budget appsec.Budget
if s.middlewareManager != nil {
budget = s.middlewareManager.Budget()
}
client, err := appsec.New(appsec.Config{
URL: s.CrowdSecAppSecURL,
APIKey: s.CrowdSecAPIKey,
Timeout: s.CrowdSecAppSecTimeout,
MaxBodyBytes: s.CrowdSecAppSecMaxBodyBytes,
MaxConcurrent: s.CrowdSecAppSecMaxConcurrent,
Budget: budget,
Logger: log.NewEntry(s.Logger),
})
if err != nil {
return fmt.Errorf("init crowdsec appsec: %w", err)
}
s.appsecClient = client
s.Logger.Infof("CrowdSec AppSec inspection available at %s", s.CrowdSecAppSecURL)
return nil
}
// appSecMode resolves the per-service AppSec mode. A service asking for
// inspection on a proxy with no AppSec endpoint keeps its mode so the auth
// middleware fails closed for enforce, mirroring the CrowdSec behavior.
func (s *Server) appSecMode(mapping *proto.ProxyMapping) restrict.AppSecMode {
raw := mapping.GetAccessRestrictions().GetAppsecMode()
mode := restrict.ParseAppSecMode(raw)
// An unrecognized value disables inspection, which is the safe default but a
// silent one: with a newer management and an older proxy, a mode this build
// does not know would look identical to "off" on a service the operator set
// to enforce. Say so rather than leaving it to be discovered.
if mode == restrict.AppSecOff && raw != "" && raw != "off" {
s.Logger.Warnf("service %s requests unrecognized AppSec mode %q; this build supports %q and %q, so inspection is disabled",
mapping.GetId(), raw, restrict.AppSecEnforce, restrict.AppSecObserve)
}
if mode.Enabled() && s.appsecClient == nil {
s.Logger.Warnf("service %s requests AppSec mode %q but proxy has no AppSec endpoint configured", mapping.GetId(), mode)
}
return mode
}
// releaseCrowdSec releases the CrowdSec bouncer reference for the given
// service if it had one.
func (s *Server) releaseCrowdSec(svcID types.ServiceID) {
@@ -2070,7 +2161,17 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
settings := auth.DomainSettings{
Schemes: schemes,
SessionPublicKey: mapping.GetAuth().GetSessionKey(),
SessionExpiration: maxSessionAge,
AccountID: accountID,
ServiceID: svcID,
IPRestrictions: ipRestrictions,
Private: mapping.GetPrivate(),
AppSecMode: s.appSecMode(mapping),
}
if err := s.auth.AddDomain(mapping.GetDomain(), settings); err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)

View File

@@ -21,6 +21,8 @@ import (
"google.golang.org/grpc/connectivity"
nbgrpc "github.com/netbirdio/netbird/client/grpc"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -62,6 +64,13 @@ type GrpcClient struct {
connStateCallbackLock sync.RWMutex
serverURL string
// netState gates the stream retry loop on OS-reported network
// availability; nil (the default) disables gating.
netState *netstate.State
// sweeper cuts the transport connections on network change; nil disables it.
sweeper *netsweep.Sweeper
// syncStreamErr holds the last Sync stream error, or nil while the stream
// is established and healthy. GetServerKey succeeds even when the peer
// cannot sync (e.g. the server returns "settings not found"), so the
@@ -111,16 +120,43 @@ func MaxRecvMsgSize() int {
return size
}
// Option configures optional GrpcClient behavior.
type Option func(*GrpcClient)
// WithNetworkState injects the OS network availability state that gates the
// stream retry loop; without it gating is disabled.
func WithNetworkState(netState *netstate.State) Option {
return func(c *GrpcClient) { c.netState = netState }
}
// WithSweeper injects the network change sweeper.
func WithSweeper(sweeper *netsweep.Sweeper) Option {
return func(c *GrpcClient) { c.sweeper = sweeper }
}
// NewClient creates a new client to Management service
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
var conn *grpc.ClientConn
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
// Options apply before dialing: the sweeper must wrap the first connection too.
c := &GrpcClient{
key: ourPrivateKey,
ctx: ctx,
connStateCallbackLock: sync.RWMutex{},
serverURL: addr,
}
for _, opt := range opts {
opt(c)
}
var extraOpts []grpc.DialOption
if maxSize := MaxRecvMsgSize(); maxSize > 0 {
extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize)))
log.Infof("management gRPC max receive message size set to %d bytes", maxSize)
}
if c.sweeper != nil {
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
}
var conn *grpc.ClientConn
operation := func() error {
var err error
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent, extraOpts...)
@@ -136,16 +172,9 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE
return nil, err
}
realClient := proto.NewManagementServiceClient(conn)
return &GrpcClient{
key: ourPrivateKey,
realClient: realClient,
ctx: ctx,
conn: conn,
connStateCallbackLock: sync.RWMutex{},
serverURL: addr,
}, nil
c.conn = conn
c.realClient = proto.NewManagementServiceClient(conn)
return c, nil
}
// GetServerURL returns the management server URL
@@ -206,16 +235,33 @@ func (c *GrpcClient) withMgmtStream(
ctx context.Context,
handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error,
) error {
backOff := defaultBackoff(ctx)
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
operation := func() error {
log.Debugf("management connection state %v", c.conn.GetState())
connState := c.conn.GetState()
// suspend reconnect attempts while the OS reports no usable network.
// Wait only errors on a cancelled context, which means shutdown, so
// stop the loop without reporting a failure.
if waited, err := c.netState.Wait(ctx); err != nil {
log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown")
return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure
} else if waited {
backOff.Reset()
}
connState := c.conn.GetState()
log.Debugf("management connection state %v", connState)
if connState == connectivity.Shutdown {
return backoff.Permanent(fmt.Errorf("connection to management has been shut down"))
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
}
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
// A dial may already be in flight (e.g. the other stream triggered
// it after a network change); wait for it to settle and proceed if
// the channel became usable, instead of burning a backoff round on
// a successful dial. A failed dial errors out as before.
c.conn.WaitForStateChange(ctx, connState)
return fmt.Errorf("connection to management is not ready and in %s state", connState)
connState = c.conn.GetState()
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
return fmt.Errorf("connection to management is not ready and in %s state", connState)
}
}
serverPubKey, err := c.getServerPublicKey()
@@ -227,7 +273,7 @@ func (c *GrpcClient) withMgmtStream(
return handler(ctx, *serverPubKey, backOff)
}
err := backoff.Retry(operation, backOff)
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
if err != nil {
log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", err)
}

View File

@@ -3380,6 +3380,30 @@ components:
- "observe"
default: "off"
description: CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
allow_match:
type: string
enum:
- "all"
- "any"
default: "all"
description: >-
How the allowlists (allowed_cidrs, allowed_countries) combine.
"all" (default) requires a connection to match every configured
allowlist (AND); "any" requires it to match at least one (OR), e.g.
an allowed country OR an allowed CIDR. Blocklists always reject on
match regardless of this setting.
appsec_mode:
type: string
enum:
- "off"
- "enforce"
- "observe"
default: "off"
description: >-
CrowdSec AppSec (WAF) request inspection mode. Only available when
the proxy cluster supports AppSec, and only applied to HTTP
services. "enforce" blocks requests the WAF flags; "observe" records
the verdict in the access log without blocking.
PasswordAuthConfig:
type: object
properties:
@@ -3516,6 +3540,10 @@ components:
type: boolean
description: Whether all active proxies in the cluster have CrowdSec configured
example: false
supports_appsec:
type: boolean
description: Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
example: false
private:
type: boolean
description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
@@ -3575,6 +3603,10 @@ components:
type: boolean
description: Whether the proxy cluster has CrowdSec configured
example: false
supports_appsec:
type: boolean
description: Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
example: false
supports_private:
type: boolean
description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.

View File

@@ -17,6 +17,45 @@ const (
TokenAuthScopes tokenAuthContextKey = "TokenAuth.Scopes"
)
// Defines values for AccessRestrictionsAllowMatch.
const (
AccessRestrictionsAllowMatchAll AccessRestrictionsAllowMatch = "all"
AccessRestrictionsAllowMatchAny AccessRestrictionsAllowMatch = "any"
)
// Valid indicates whether the value is a known member of the AccessRestrictionsAllowMatch enum.
func (e AccessRestrictionsAllowMatch) Valid() bool {
switch e {
case AccessRestrictionsAllowMatchAll:
return true
case AccessRestrictionsAllowMatchAny:
return true
default:
return false
}
}
// Defines values for AccessRestrictionsAppsecMode.
const (
AccessRestrictionsAppsecModeEnforce AccessRestrictionsAppsecMode = "enforce"
AccessRestrictionsAppsecModeObserve AccessRestrictionsAppsecMode = "observe"
AccessRestrictionsAppsecModeOff AccessRestrictionsAppsecMode = "off"
)
// Valid indicates whether the value is a known member of the AccessRestrictionsAppsecMode enum.
func (e AccessRestrictionsAppsecMode) Valid() bool {
switch e {
case AccessRestrictionsAppsecModeEnforce:
return true
case AccessRestrictionsAppsecModeObserve:
return true
case AccessRestrictionsAppsecModeOff:
return true
default:
return false
}
}
// Defines values for AccessRestrictionsCrowdsecMode.
const (
AccessRestrictionsCrowdsecModeEnforce AccessRestrictionsCrowdsecMode = "enforce"
@@ -1534,12 +1573,18 @@ func (e PutApiIntegrationsMspTenantsIdInviteJSONBodyValue) Valid() bool {
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
type AccessRestrictions struct {
// AllowMatch How the allowlists (allowed_cidrs, allowed_countries) combine. "all" (default) requires a connection to match every configured allowlist (AND); "any" requires it to match at least one (OR), e.g. an allowed country OR an allowed CIDR. Blocklists always reject on match regardless of this setting.
AllowMatch *AccessRestrictionsAllowMatch `json:"allow_match,omitempty"`
// AllowedCidrs CIDR allowlist. If non-empty, only IPs matching these CIDRs are allowed.
AllowedCidrs *[]string `json:"allowed_cidrs,omitempty"`
// AllowedCountries ISO 3166-1 alpha-2 country codes to allow. If non-empty, only these countries are permitted.
AllowedCountries *[]string `json:"allowed_countries,omitempty"`
// AppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
AppsecMode *AccessRestrictionsAppsecMode `json:"appsec_mode,omitempty"`
// BlockedCidrs CIDR blocklist. Connections from these CIDRs are rejected. Evaluated after allowed_cidrs.
BlockedCidrs *[]string `json:"blocked_cidrs,omitempty"`
@@ -1550,6 +1595,12 @@ type AccessRestrictions struct {
CrowdsecMode *AccessRestrictionsCrowdsecMode `json:"crowdsec_mode,omitempty"`
}
// AccessRestrictionsAllowMatch How the allowlists (allowed_cidrs, allowed_countries) combine. "all" (default) requires a connection to match every configured allowlist (AND); "any" requires it to match at least one (OR), e.g. an allowed country OR an allowed CIDR. Blocklists always reject on match regardless of this setting.
type AccessRestrictionsAllowMatch string
// AccessRestrictionsAppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
type AccessRestrictionsAppsecMode string
// AccessRestrictionsCrowdsecMode CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
type AccessRestrictionsCrowdsecMode string
@@ -4773,6 +4824,9 @@ type ProxyCluster struct {
// RequireSubdomain Whether services on this cluster must include a subdomain label
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
// SupportsAppsec Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
// SupportsCrowdsec Whether all active proxies in the cluster have CrowdSec configured
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`
@@ -4841,6 +4895,9 @@ type ReverseProxyDomain struct {
// RequireSubdomain Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare.
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
// SupportsAppsec Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
// SupportsCrowdsec Whether the proxy cluster has CrowdSec configured
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`

File diff suppressed because it is too large Load Diff

View File

@@ -73,6 +73,10 @@ message ProxyCapabilities {
optional bool private = 4;
// Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
optional bool supports_private_service = 5;
// Whether the proxy has a CrowdSec AppSec (WAF) endpoint configured and can
// inspect HTTP requests. Independent of supports_crowdsec: AppSec is a
// separate endpoint on the Security Engine and applies to HTTP services only.
optional bool supports_appsec = 6;
}
// GetMappingUpdateRequest is sent to initialise a mapping stream.
@@ -203,6 +207,14 @@ message AccessRestrictions {
repeated string blocked_countries = 4;
// CrowdSec IP reputation mode: "", "off", "enforce", or "observe".
string crowdsec_mode = 5;
// How the allowlists (CIDR, country) combine: "" or "all" require matching
// every allowlist (AND); "any" requires matching at least one (OR).
// Blocklists are always a hard-deny gate, independent of this mode.
string allow_match = 6;
// CrowdSec AppSec (WAF) request inspection mode: "", "off", "enforce", or
// "observe". HTTP services only: "enforce" and "observe" are rejected at
// validation for TCP/UDP/TLS services, which carry no requests to inspect.
string appsec_mode = 7;
}
message ProxyMapping {

View File

@@ -14,6 +14,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netsweep"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
"github.com/netbirdio/netbird/shared/relay/client/dialer"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
@@ -184,6 +185,10 @@ type Client struct {
// datagram-sized transport is avoided on subsequent connects. Shared via
// the manager.
transportFallback *transportFallback
// sweeper cuts the relay connection on network change; the read loop
// reports the disconnect and the guard reconnects. Shared via the manager.
sweeper *netsweep.Sweeper
// datagramFallbackTriggered guards a single fallback per connection so a
// burst of oversized datagrams triggers one reconnect, not many.
datagramFallbackTriggered atomic.Bool
@@ -393,6 +398,12 @@ func (c *Client) Close() error {
}
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
// A sweep cancels this context, so a dial started on the old network
// aborts instead of waiting out its handshake timeout.
dial := c.sweeper.StartDial(ctx)
defer dial.Release()
ctx = dial.Ctx()
mode := transportModeFromEnv()
dialers := c.getDialers(mode)
@@ -417,12 +428,19 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
return nil, fmt.Errorf("dial via FQDN: %w", err)
}
}
c.relayConn = conn
c.datagramFallbackTriggered.Store(false)
// Read the transport off the concrete connection: the sweeper's wrapper
// embeds net.Conn only, so it does not promote Protocol().
if tc, ok := conn.(transportConn); ok {
c.transport = tc.Protocol()
}
conn, err := dial.WrapConn(conn)
if err != nil {
return nil, fmt.Errorf("register connection: %w", err)
}
c.relayConn = conn
c.datagramFallbackTriggered.Store(false)
instanceURL, err := c.handShake(ctx)
if err != nil {
cErr := conn.Close()

View File

@@ -7,9 +7,22 @@ import (
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
)
const defaultMaxBackoffInterval = 60 * time.Second
const (
defaultMaxBackoffInterval = 60 * time.Second
// quickReconnectBudget bounds how long a quick reconnect waits for the
// network before handing the retry over to the ticker.
quickReconnectBudget = 1500 * time.Millisecond
// verdictSettleWindow is how long an online verdict must hold before it
// is trusted: the disconnect often precedes the OS offline flag by a few
// milliseconds.
verdictSettleWindow = 200 * time.Millisecond
)
// Guard manage the reconnection tries to the Relay server in case of disconnection event.
type Guard struct {
@@ -22,14 +35,19 @@ type Guard struct {
// attempts.
maxBackoffInterval time.Duration
// netState gates reconnect attempts on OS-reported network availability;
// nil disables gating.
netState *netstate.State
// lastErr is the error from the most recent failed reconnect attempt,
// surfaced as the home relay status while disconnected.
lastErr atomic.Pointer[error]
}
// NewGuard creates a new guard for the relay client. A non-positive
// maxBackoffInterval falls back to defaultMaxBackoffInterval.
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
// maxBackoffInterval falls back to defaultMaxBackoffInterval. A nil netState
// disables network availability gating.
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *netstate.State) *Guard {
if maxBackoffInterval <= 0 {
maxBackoffInterval = defaultMaxBackoffInterval
}
@@ -38,6 +56,7 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
OnReconnected: make(chan struct{}, 1),
serverPicker: sp,
maxBackoffInterval: maxBackoffInterval,
netState: netState,
}
return g
}
@@ -70,11 +89,21 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
// start a ticker to pick a new server
ticker := g.exponentTicker(ctx)
defer ticker.Stop()
defer func() {
ticker.Stop()
}()
for {
select {
case <-ticker.C:
// suspend reconnect attempts while the OS reports no usable network
if waited, err := g.netState.Wait(ctx); err != nil {
return
} else if waited {
ticker.Stop()
ticker = g.exponentTicker(ctx)
continue
}
if err := g.retry(ctx); err != nil {
log.Errorf("failed to pick new Relay server: %s", err)
g.setLastError(err)
@@ -100,7 +129,12 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
return false
}
if cancelled := waiteBeforeRetry(parentCtx); !cancelled {
if ok := g.waitForNetwork(parentCtx); !ok {
return false
}
// Still offline after the budget: leave the retry to the ticker.
if !g.netState.IsOnline() {
return false
}
@@ -166,14 +200,47 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
return backoff.NewTicker(bo)
}
func waiteBeforeRetry(ctx context.Context) bool {
timer := time.NewTimer(1500 * time.Millisecond)
defer timer.Stop()
// waitForNetwork waits out the settle window while online, or waits for the
// network to return while offline, within the budget. Returns false when ctx
// is cancelled. Without an injected netState it degrades to a fixed
// budget-long sleep, the pre-netstate behavior.
func (g *Guard) waitForNetwork(ctx context.Context) bool {
budget := time.NewTimer(quickReconnectBudget)
defer budget.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
settleWindow := verdictSettleWindow
if g.netState == nil {
settleWindow = quickReconnectBudget
}
settle := time.NewTimer(settleWindow)
defer settle.Stop()
for {
// Channel first, flag second: a flip in between still fires the channel.
changedCh := g.netState.Changed()
if g.netState.IsOnline() {
select {
case <-settle.C:
return true
case <-changedCh:
case <-ctx.Done():
return false
}
} else {
select {
case <-budget.C:
return true
case <-changedCh:
case <-ctx.Done():
return false
}
}
if !settle.Stop() {
select {
case <-settle.C:
default:
}
}
settle.Reset(settleWindow)
}
}

View File

@@ -0,0 +1,30 @@
package client
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/netstate"
)
func TestWaitForNetworkSettlesAfterOutage(t *testing.T) {
ns := netstate.New()
ns.Set(false)
g := NewGuard(nil, 0, ns)
const outage = 2 * verdictSettleWindow
start := time.Now()
go func() {
time.Sleep(outage)
ns.Set(true)
}()
ok := g.waitForNetwork(context.Background())
elapsed := time.Since(start)
assert.True(t, ok, "recovered network must let the quick reconnect proceed")
assert.GreaterOrEqual(t, elapsed, outage+verdictSettleWindow, "reconnect must wait a full settle window after the network returns")
}

View File

@@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
relayAuth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
)
@@ -65,6 +67,17 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption {
return func(m *Manager) { m.maxBackoffInterval = d }
}
// WithNetworkState injects the OS network availability state that gates the
// reconnect guard; without it reconnect attempts are not gated.
func WithNetworkState(netState *netstate.State) ManagerOption {
return func(m *Manager) { m.netState = netState }
}
// WithSweeper injects the network change sweeper.
func WithSweeper(sweeper *netsweep.Sweeper) ManagerOption {
return func(m *Manager) { m.sweeper = sweeper }
}
// Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
// and automatically reconnect to them in case disconnection.
// The manager also manage temporary relay connection. If a client wants to communicate with a client on a
@@ -92,6 +105,8 @@ type Manager struct {
mtu uint16
maxBackoffInterval time.Duration
netState *netstate.State
sweeper *netsweep.Sweeper
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
@@ -128,8 +143,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
for _, opt := range opts {
opt(m)
}
m.serverPicker.Sweeper = m.sweeper
m.serverPicker.ServerURLs.Store(serverURLs)
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval)
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netState)
return m
}
@@ -354,6 +370,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
relayClient.SetTransportFallback(m.transportFallback)
relayClient.sweeper = m.sweeper
err := relayClient.Connect(m.ctx)
if err != nil {
rt.Lock()

View File

@@ -9,6 +9,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netsweep"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
)
@@ -30,6 +31,7 @@ type ServerPicker struct {
MTU uint16
ConnectionTimeout time.Duration
TransportFallback *transportFallback
Sweeper *netsweep.Sweeper
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -73,6 +75,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
relayClient.SetTransportFallback(sp.TransportFallback)
relayClient.sweeper = sp.Sweeper
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,

View File

@@ -19,6 +19,8 @@ import (
"google.golang.org/grpc/status"
nbgrpc "github.com/netbirdio/netbird/client/grpc"
"github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/signal/proto"
@@ -65,6 +67,13 @@ type GrpcClient struct {
connStateCallback ConnStateNotifier
connStateCallbackLock sync.RWMutex
// netState gates the Receive retry loop on OS-reported network
// availability; nil (the default) disables gating.
netState *netstate.State
// sweeper cuts the transport connections on network change; nil disables it.
sweeper *netsweep.Sweeper
onReconnectedListenerFn func()
decryptionWorker *Worker
@@ -88,13 +97,43 @@ type GrpcClient struct {
watchdogWg sync.WaitGroup
}
// NewClient creates a new Signal client
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
var conn *grpc.ClientConn
// Option configures optional GrpcClient behavior.
type Option func(*GrpcClient)
// WithNetworkState injects the OS network availability state that gates the
// Receive retry loop; without it gating is disabled.
func WithNetworkState(netState *netstate.State) Option {
return func(c *GrpcClient) { c.netState = netState }
}
// WithSweeper injects the network change sweeper.
func WithSweeper(sweeper *netsweep.Sweeper) Option {
return func(c *GrpcClient) { c.sweeper = sweeper }
}
// NewClient creates a new Signal client
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
// Options apply before dialing: the sweeper must wrap the first connection too.
c := &GrpcClient{
ctx: ctx,
key: key,
mux: sync.Mutex{},
status: StreamDisconnected,
connStateCallbackLock: sync.RWMutex{},
}
for _, opt := range opts {
opt(c)
}
var extraOpts []grpc.DialOption
if c.sweeper != nil {
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
}
var conn *grpc.ClientConn
operation := func() error {
var err error
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent)
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent, extraOpts...)
if err != nil {
return fmt.Errorf("create connection: %w", err)
}
@@ -109,15 +148,9 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo
log.Debugf("connected to Signal Service: %v", conn.Target())
return &GrpcClient{
realClient: proto.NewSignalExchangeClient(conn),
ctx: ctx,
signalConn: conn,
key: key,
mux: sync.Mutex{},
status: StreamDisconnected,
connStateCallbackLock: sync.RWMutex{},
}, nil
c.signalConn = conn
c.realClient = proto.NewSignalExchangeClient(conn)
return c, nil
}
func (c *GrpcClient) StreamConnected() bool {
@@ -165,19 +198,36 @@ func defaultBackoff(ctx context.Context) backoff.BackOff {
// The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller.
func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error {
var backOff = defaultBackoff(ctx)
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
operation := func() error {
// suspend reconnect attempts while the OS reports no usable network.
// Wait only errors on a cancelled context, which means shutdown, so
// stop the loop without reporting a failure.
if waited, err := c.netState.Wait(ctx); err != nil {
log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown")
return nil
} else if waited {
backOff.Reset()
}
c.notifyStreamDisconnected()
log.Debugf("signal connection state %v", c.signalConn.GetState())
connState := c.signalConn.GetState()
log.Debugf("signal connection state %v", connState)
if connState == connectivity.Shutdown {
return backoff.Permanent(fmt.Errorf("connection to signal has been shut down"))
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
}
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
// A dial may already be in flight (e.g. triggered by another RPC
// after a network change); wait for it to settle and proceed if
// the channel became usable, instead of burning a backoff round on
// a successful dial. A failed dial errors out as before.
c.signalConn.WaitForStateChange(ctx, connState)
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
connState = c.signalConn.GetState()
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
}
}
// connect to Signal stream identifying ourselves with a public WireGuard key
@@ -231,7 +281,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
return nil
}
err := backoff.Retry(operation, backOff)
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
if err != nil {
log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err)
return err