mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # client/proto/daemon.pb.go # management/internals/controllers/network_map/controller/controller.go # management/internals/controllers/network_map/nmaptest/runner.go # management/internals/shared/grpc/server.go # management/server/account.go # shared/management/proto/management.pb.go # upload-server/server/server.go
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenInfoMatchesAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token TokenInfo
|
||||
hint string
|
||||
match bool
|
||||
}{
|
||||
{
|
||||
name: "same account",
|
||||
token: TokenInfo{EmailClaim: "user@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "different account",
|
||||
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
name: "case differences are the same account",
|
||||
token: TokenInfo{EmailClaim: "User@Example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "no hint leaves the choice to the IdP",
|
||||
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||
hint: "",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "token without an email claim is not judged",
|
||||
token: TokenInfo{EmailClaim: ""},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "name fallback does not trigger matching",
|
||||
token: TokenInfo{Email: "Some One"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmailFromIDToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claims map[string]interface{}
|
||||
wantValue string
|
||||
wantFromEmail bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "email claim",
|
||||
claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"},
|
||||
wantValue: "user@example.com",
|
||||
wantFromEmail: true,
|
||||
},
|
||||
{
|
||||
name: "name fallback",
|
||||
claims: map[string]interface{}{"name": "Some One"},
|
||||
wantValue: "Some One",
|
||||
},
|
||||
{
|
||||
name: "neither claim present",
|
||||
claims: map[string]interface{}{"sub": "abc"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
value, fromEmailClaim, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims))
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantValue, value)
|
||||
assert.Equal(t, tc.wantFromEmail, fromEmailClaim)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) {
|
||||
assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{}))
|
||||
}
|
||||
|
||||
func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(claims)
|
||||
require.NoError(t, err)
|
||||
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
// Try PKCE flow first
|
||||
_, err := a.getPKCEFlow(client)
|
||||
_, err := a.getPKCEFlow(client, false)
|
||||
if err == nil {
|
||||
supportsSSO = true
|
||||
return nil
|
||||
@@ -136,9 +136,13 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
return supportsSSO, err
|
||||
}
|
||||
|
||||
// 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, hint string) (OAuthFlow, error) {
|
||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection.
|
||||
// This avoids creating a new connection to the management server.
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in; the server needs it to rule out a silent authorization that the
|
||||
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool, hint string) (OAuthFlow, error) {
|
||||
var flow OAuthFlow
|
||||
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
@@ -153,7 +157,7 @@ func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint stri
|
||||
}
|
||||
|
||||
// Try PKCE flow first
|
||||
pkceFlow, err := a.getPKCEFlow(client)
|
||||
pkceFlow, err := a.getPKCEFlow(client, sessionExtend)
|
||||
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) {
|
||||
@@ -240,8 +244,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
}
|
||||
|
||||
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow()
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
||||
|
||||
@@ -308,10 +308,13 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
||||
// callers store to send back as the login_hint. Without it a client
|
||||
// driven through the device flow — Android TV and tvOS — never binds
|
||||
// an account to its profile and every later login goes out blind.
|
||||
if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||
if email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||
log.Warnf("failed to parse email from ID token: %v", err)
|
||||
} else {
|
||||
tokenInfo.Email = email
|
||||
if fromEmailClaim {
|
||||
tokenInfo.EmailClaim = email
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -25,6 +26,14 @@ type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// accountPromptForcer is implemented by the PKCE flow only. The device code
|
||||
// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user
|
||||
// confirms the code on a page that shows which account signs in, so a silent
|
||||
// wrong-account answer is not the failure mode there.
|
||||
type accountPromptForcer interface {
|
||||
ForceAccountPrompt()
|
||||
}
|
||||
|
||||
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
||||
type AuthFlowInfo struct { //nolint:revive
|
||||
DeviceCode string `json:"device_code"`
|
||||
@@ -49,6 +58,23 @@ type TokenInfo struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
UseIDToken bool `json:"-"`
|
||||
Email string `json:"-"`
|
||||
EmailClaim string `json:"-"`
|
||||
}
|
||||
|
||||
// MatchesAccount reports whether the token belongs to the account a profile is
|
||||
// bound to. A hint the IdP could not have acted on — no hint stored, or a token
|
||||
// that carried no email claim — is reported as a match: the check exists to catch a
|
||||
// login answered from the wrong account, not to block one it cannot judge.
|
||||
//
|
||||
// The comparison is case-insensitive. Local-parts are case-sensitive per RFC
|
||||
// 5321, but no IdP in practice issues two accounts differing only in case, and
|
||||
// an IdP that echoes a differently-cased address would otherwise fail every
|
||||
// login.
|
||||
func (t TokenInfo) MatchesAccount(hint string) bool {
|
||||
if hint == "" || t.EmailClaim == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(t.EmailClaim, hint)
|
||||
}
|
||||
|
||||
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
||||
@@ -63,19 +89,22 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
|
||||
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
|
||||
}
|
||||
|
||||
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration
|
||||
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration.
|
||||
//
|
||||
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
|
||||
// and if that also fails, the authentication process is deemed unsuccessful
|
||||
//
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
|
||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow.
|
||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV).
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in. See PKCEAuthorizationFlowRequest for what the server makes of it.
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
|
||||
return authenticateWithDeviceCodeFlow(ctx, config, hint)
|
||||
}
|
||||
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
|
||||
if err != nil {
|
||||
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
|
||||
log.Debug("falling back to device code flow")
|
||||
@@ -85,14 +114,14 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
|
||||
}
|
||||
|
||||
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create auth client: %v", err)
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
}
|
||||
@@ -129,3 +158,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
||||
|
||||
return deviceFlowInfo, nil
|
||||
}
|
||||
|
||||
// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for
|
||||
// a login answered with an account other than the one hinted. Returns nil when
|
||||
// the flow cannot ask — the caller then proceeds with the token it has.
|
||||
//
|
||||
// Proceeding rather than failing is deliberate. The hint is an email that may
|
||||
// simply have changed since it was stored, and refusing the login would lock a
|
||||
// user out of their own profile over a rename. The retry gives the account a
|
||||
// chance to be corrected; the server still rejects a token that does not own
|
||||
// the peer.
|
||||
func RetryFlowForAccount(flow OAuthFlow) OAuthFlow {
|
||||
forcer, ok := flow.(accountPromptForcer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
forcer.ForceAccountPrompt()
|
||||
return flow
|
||||
}
|
||||
|
||||
@@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
|
||||
// PKCEAuthorizationFlow implements the OAuthFlow interface for
|
||||
// the Authorization Code Flow with PKCE.
|
||||
type PKCEAuthorizationFlow struct {
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
forceAccountPrompt bool
|
||||
}
|
||||
|
||||
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
|
||||
@@ -153,11 +154,16 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
|
||||
}
|
||||
forceAccountPrompt := p.forceAccountPrompt
|
||||
p.forceAccountPrompt = false
|
||||
|
||||
if !p.providerConfig.DisablePromptLogin {
|
||||
switch p.providerConfig.LoginFlag {
|
||||
case common.LoginFlagPromptLogin:
|
||||
switch {
|
||||
case forceAccountPrompt:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case common.LoginFlagMaxAge0:
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0:
|
||||
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
|
||||
}
|
||||
}
|
||||
@@ -178,6 +184,20 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) {
|
||||
p.providerConfig.LoginHint = hint
|
||||
}
|
||||
|
||||
// ForceAccountPrompt makes the next authorization request ask the IdP to
|
||||
// re-authenticate instead of answering from the session it already holds. Used
|
||||
// to retry a login that came back for an account other than the one hinted.
|
||||
//
|
||||
// The next RequestAuthInfo consumes the flag, so a flow that outlives its retry
|
||||
// goes back to the configured behaviour instead of re-authenticating forever.
|
||||
//
|
||||
// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login,
|
||||
// where retrying with it would replace a wrong-account login with one that
|
||||
// cannot complete at all.
|
||||
func (p *PKCEAuthorizationFlow) ForceAccountPrompt() {
|
||||
p.forceAccountPrompt = true
|
||||
}
|
||||
|
||||
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
|
||||
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
|
||||
// Once the token is received, it is converted to TokenInfo and validated before returning.
|
||||
@@ -310,49 +330,48 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||
}
|
||||
|
||||
email, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||
email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse email from ID token: %v", err)
|
||||
} else {
|
||||
tokenInfo.Email = email
|
||||
if fromEmailClaim {
|
||||
tokenInfo.EmailClaim = email
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||
// without verifying its signature. The value is best-effort and used only as a
|
||||
// UX convenience (login hint prefill and display); it never drives an
|
||||
// authorization decision. The authoritative identity is established server-side
|
||||
// from the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
// without verifying its signature. The value is best-effort: it prefills the
|
||||
// login hint and is displayed. Account matching (see MatchesAccount) only uses
|
||||
// it when it came from the email claim, which fromEmailClaim reports. It never
|
||||
// grants anything — the authoritative identity is established server-side from
|
||||
// the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (value string, fromEmailClaim bool, err error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid token format")
|
||||
return "", false, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode payload: %w", err)
|
||||
return "", false, fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return "", fmt.Errorf("json unmarshal error: %w", err)
|
||||
return "", false, fmt.Errorf("json unmarshal error: %w", err)
|
||||
}
|
||||
|
||||
var email string
|
||||
if emailValue, ok := claims["email"].(string); ok {
|
||||
email = emailValue
|
||||
} else {
|
||||
val, ok := claims["name"].(string)
|
||||
if ok {
|
||||
email = val
|
||||
} else {
|
||||
return "", fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
return email, true, nil
|
||||
}
|
||||
if name, ok := claims["name"].(string); ok {
|
||||
return name, false, nil
|
||||
}
|
||||
|
||||
return email, nil
|
||||
return "", false, fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
|
||||
func createCodeChallenge(codeVerifier string) string {
|
||||
|
||||
@@ -76,6 +76,32 @@ func TestPromptLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceAccountPromptAppliesOnlyToTheRetry(t *testing.T) {
|
||||
config := PKCEAuthProviderConfig{
|
||||
ClientID: "test-client-id",
|
||||
Audience: "test-audience",
|
||||
TokenEndpoint: "https://test-token-endpoint.com/token",
|
||||
Scope: "openid email profile",
|
||||
AuthorizationEndpoint: "https://test-auth-endpoint.com/authorize",
|
||||
RedirectURLs: []string{"http://127.0.0.1:33992/"},
|
||||
UseIDToken: true,
|
||||
LoginFlag: mgm.LoginFlagNone,
|
||||
}
|
||||
pkce, err := NewPKCEAuthorizationFlow(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkce.ForceAccountPrompt()
|
||||
|
||||
retry, err := pkce.RequestAuthInfo(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, retry.VerificationURIComplete, "prompt=login")
|
||||
|
||||
next, err := pkce.RequestAuthInfo(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, next.VerificationURIComplete, "prompt=login",
|
||||
"the forced prompt outlived the retry it was armed for")
|
||||
}
|
||||
|
||||
func TestIsPortInExcludedRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -90,8 +90,9 @@ type StatusRecorder interface {
|
||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||
// the first one for the same deadline). Safe for concurrent use.
|
||||
type Watcher struct {
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
deadlineOnly bool
|
||||
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
@@ -102,6 +103,7 @@ type Watcher struct {
|
||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||
closed bool
|
||||
recorder StatusRecorder
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// New returns a watcher with the package defaults WarningLead and
|
||||
@@ -122,9 +124,17 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
lead: lead,
|
||||
finalLead: final,
|
||||
recorder: recorder,
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDeadlineOnly returns a watcher that validates and records deadlines but arms no warning timers.
|
||||
func NewDeadlineOnly(recorder StatusRecorder) *Watcher {
|
||||
w := New(recorder)
|
||||
w.deadlineOnly = true
|
||||
return w
|
||||
}
|
||||
|
||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||
// a Sync push from the server omits the field because login expiration
|
||||
// was disabled).
|
||||
@@ -181,7 +191,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
if deadline.After(now) {
|
||||
if deadline.After(now) && !w.deadlineOnly {
|
||||
w.armTimerLocked(deadline)
|
||||
}
|
||||
recorder := w.recorder
|
||||
@@ -303,6 +313,11 @@ func (w *Watcher) fire(armedFor time.Time) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
now := w.nowFn()
|
||||
if isLate(now, armedFor, max(w.finalLead, 0)) {
|
||||
w.fireLateLocked(armedFor, now)
|
||||
return
|
||||
}
|
||||
w.firedAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
@@ -331,6 +346,14 @@ func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||
return
|
||||
}
|
||||
now := w.nowFn()
|
||||
if isLate(now, armedFor, 0) {
|
||||
w.finalFiredAt = armedFor
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session final-warning skipped for deadline %s (passed %s ago)",
|
||||
armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second))
|
||||
return
|
||||
}
|
||||
w.finalFiredAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
@@ -341,6 +364,39 @@ func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||
publishWarning(recorder, armedFor, true)
|
||||
}
|
||||
|
||||
// fireLateLocked handles a T-WarningLead callback that fired inside the
|
||||
// final-warning window: it sends the final warning in its place while the
|
||||
// deadline has not passed and the user has not dismissed it, so a resume
|
||||
// with time left still warns. The caller must hold w.mu; this helper
|
||||
// releases it.
|
||||
func (w *Watcher) fireLateLocked(armedFor, now time.Time) {
|
||||
w.firedAt = armedFor
|
||||
switch {
|
||||
case w.dismissedAt.Equal(armedFor):
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session expiry soon warning skipped (dismissed by user)")
|
||||
return
|
||||
case w.finalFiredAt.Equal(armedFor):
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session expiry soon warning skipped (final warning already fired)")
|
||||
return
|
||||
case isLate(now, armedFor, 0):
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session expiry soon warning skipped for deadline %s (passed %s ago)",
|
||||
armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second))
|
||||
return
|
||||
}
|
||||
w.finalFiredAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session expiry soon warning fired inside the final-warning window, sending final warning for deadline %s",
|
||||
armedFor.Format(time.RFC3339))
|
||||
publishWarning(recorder, armedFor, true)
|
||||
}
|
||||
|
||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||
// notification (invoked after w.mu is released) lands first. Caller must
|
||||
@@ -380,3 +436,11 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||
meta,
|
||||
)
|
||||
}
|
||||
|
||||
// isLate reports whether the wall clock now has already reached armedFor
|
||||
// minus cutoffLead. The timers run on the monotonic clock, which can stall
|
||||
// while the host sleeps, so a timer can fire long after the window it was
|
||||
// armed for.
|
||||
func isLate(now, armedFor time.Time, cutoffLead time.Duration) bool {
|
||||
return !now.Round(0).Before(armedFor.Add(-cutoffLead).Round(0))
|
||||
}
|
||||
|
||||
@@ -527,3 +527,201 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||
}
|
||||
|
||||
func TestIsLate(t *testing.T) {
|
||||
armedFor := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||
lead := 2 * time.Minute
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
cutoffLead time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"before cutoff", armedFor.Add(-3 * time.Minute), lead, false},
|
||||
{"at cutoff", armedFor.Add(-lead), lead, true},
|
||||
{"after cutoff", armedFor.Add(-time.Minute), lead, true},
|
||||
{"zero lead before deadline", armedFor.Add(-time.Second), 0, false},
|
||||
{"zero lead at deadline", armedFor, 0, true},
|
||||
{"zero lead after deadline", armedFor.Add(time.Second), 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLate(tt.now, armedFor, tt.cutoffLead); got != tt.want {
|
||||
t.Fatalf("isLate(%s, %s, %s) = %v, want %v", tt.now, armedFor, tt.cutoffLead, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLateIgnoresMonotonicReading(t *testing.T) {
|
||||
now := time.Now()
|
||||
wallOnly := now.Round(0)
|
||||
if isLate(now, wallOnly.Add(time.Second), 0) {
|
||||
t.Fatalf("now with monotonic reading must compare as wall clock before a later wall-only deadline")
|
||||
}
|
||||
if !isLate(now, wallOnly, 0) {
|
||||
t.Fatalf("now with monotonic reading must compare as wall clock at an equal wall-only deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateTimerFiring(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
final bool
|
||||
beforeDl time.Duration
|
||||
wantWarns int
|
||||
wantFinals int
|
||||
}{
|
||||
{"warning on resume inside window", false, 3 * time.Minute, 1, 0},
|
||||
{"warning promoted to final inside final window", false, time.Minute, 0, 1},
|
||||
{"warning skipped past deadline", false, -time.Minute, 0, 0},
|
||||
{"final on resume before deadline", true, time.Minute, 0, 1},
|
||||
{"final skipped past deadline", true, -time.Minute, 0, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := New(r)
|
||||
defer w.Close()
|
||||
|
||||
// The deadline is an hour out so the real timers never fire
|
||||
// during the test; the late callback is invoked directly with an
|
||||
// injected clock that simulates a resume near the deadline.
|
||||
d := time.Now().Add(time.Hour).Round(0)
|
||||
w.nowFn = func() time.Time { return d.Add(-tt.beforeDl) }
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
if tt.final {
|
||||
w.fireFinal(d)
|
||||
} else {
|
||||
w.fire(d)
|
||||
}
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, event.isWarning); got != tt.wantWarns {
|
||||
t.Fatalf("expected %d warning publishes, got %d: %+v", tt.wantWarns, got, events)
|
||||
}
|
||||
if got := countWhere(events, event.isFinalWarning); got != tt.wantFinals {
|
||||
t.Fatalf("expected %d final-warning publishes, got %d: %+v", tt.wantFinals, got, events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotedFinalWarningIsNotRepeated(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := New(r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour).Round(0)
|
||||
now := d.Add(-time.Minute)
|
||||
w.nowFn = func() time.Time { return now }
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
w.fire(d)
|
||||
// The final timer was suspended too, so it fires even later than the
|
||||
// warning timer, here still just before the deadline.
|
||||
now = d.Add(-30 * time.Second)
|
||||
w.fireFinal(d)
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, event.isFinalWarning); got != 1 {
|
||||
t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events)
|
||||
}
|
||||
if got := countWhere(events, event.isWarning); got != 0 {
|
||||
t.Fatalf("expected no regular warning publish, got %d: %+v", got, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionRespectsDismiss(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := New(r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour).Round(0)
|
||||
w.nowFn = func() time.Time { return d.Add(-time.Minute) }
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
w.Dismiss()
|
||||
w.fire(d)
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
|
||||
t.Fatalf("expected no publish after dismiss, got %d: %+v", got, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionSkippedWhenFinalAlreadyFired(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := New(r)
|
||||
defer w.Close()
|
||||
|
||||
// Both timers fall in the past after a long suspend and are dispatched
|
||||
// with a zero delay, so the final callback can run before the warning one.
|
||||
d := time.Now().Add(time.Hour).Round(0)
|
||||
w.nowFn = func() time.Time { return d.Add(-time.Minute) }
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
w.fireFinal(d)
|
||||
w.fire(d)
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, event.isFinalWarning); got != 1 {
|
||||
t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events)
|
||||
}
|
||||
if got := countWhere(events, event.isWarning); got != 0 {
|
||||
t.Fatalf("expected no regular warning publish, got %d: %+v", got, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewDeadlineOnly(r)
|
||||
defer w.Close()
|
||||
|
||||
// With the default leads this deadline would otherwise fire both
|
||||
// timers on the next tick.
|
||||
d := time.Now().Add(50 * time.Millisecond).Round(0)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("expected recorder deadline %v, got %v", d, got)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
|
||||
t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events)
|
||||
}
|
||||
if w.timer != nil || w.finalTimer != nil {
|
||||
t.Fatal("expected no timers armed in deadline-only mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewDeadlineOnly(r)
|
||||
defer w.Close()
|
||||
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("expected ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("expected recorder cleared after rejection, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.updateManager != nil {
|
||||
c.updateManager.ResetMode()
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netMgr.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
|
||||
@@ -379,9 +379,38 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
}
|
||||
}
|
||||
|
||||
// bundleFilePattern names the bundle zips Generate creates in tempDir; the
|
||||
// asterisk is filled in by os.CreateTemp.
|
||||
const bundleFilePattern = "netbird.debug.*.zip"
|
||||
|
||||
const exportedBundlePrefix = "netbird.debug-file."
|
||||
|
||||
const exportedBundleMaxAge = 24 * time.Hour
|
||||
|
||||
// RemoveStaleBundles deletes bundle zips that an interrupted generation or
|
||||
// upload left behind in dir. Only files older than maxAge go, so a bundle that
|
||||
// another caller is still writing or uploading in the same directory survives.
|
||||
// Exported bundles are kept for exportedBundleMaxAge instead.
|
||||
func RemoveStaleBundles(dir string, maxAge time.Duration) {
|
||||
removeStaleFiles(dir, bundleFilePattern, maxAge)
|
||||
removeStaleFiles(dir, exportedBundlePrefix+"*.zip", exportedBundleMaxAge)
|
||||
}
|
||||
|
||||
// ExportBundle renames a generated bundle out of the RemoveStaleBundles pattern
|
||||
// and returns the new path. The caller owns the file from then on; an export
|
||||
// abandoned for longer than exportedBundleMaxAge is removed by RemoveStaleBundles.
|
||||
func ExportBundle(path string) (string, error) {
|
||||
base := strings.TrimPrefix(filepath.Base(path), strings.SplitN(bundleFilePattern, "*", 2)[0])
|
||||
exported := filepath.Join(filepath.Dir(path), exportedBundlePrefix+base)
|
||||
if err := os.Rename(path, exported); err != nil {
|
||||
return "", fmt.Errorf("export debug bundle: %w", err)
|
||||
}
|
||||
return exported, nil
|
||||
}
|
||||
|
||||
// Generate creates a debug bundle and returns the location.
|
||||
func (g *BundleGenerator) Generate() (resp string, err error) {
|
||||
bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip")
|
||||
bundlePath, err := os.CreateTemp(g.tempDir, bundleFilePattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create zip file: %w", err)
|
||||
}
|
||||
@@ -1725,3 +1754,26 @@ func anonymizeSlice(v []any, anonymizer *anonymize.Anonymizer) []any {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func removeStaleFiles(dir, pattern string, maxAge time.Duration) {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
if err != nil {
|
||||
log.Debugf("glob stale debug bundles in %s: %v", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
for _, path := range matches {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.ModTime().After(cutoff) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
log.Warnf("remove stale debug bundle %s: %v", path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Infof("removed stale debug bundle %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -845,6 +846,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||
"Name": "non-config: profile name is not needed for debug purposes",
|
||||
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||
"probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with",
|
||||
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
|
||||
}
|
||||
|
||||
@@ -969,3 +971,52 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||
func newAnonymizerForTest() *anonymize.Anonymizer {
|
||||
return anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
}
|
||||
|
||||
func TestRemoveStaleBundles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stale := filepath.Join(dir, "netbird.debug.111.zip")
|
||||
fresh := filepath.Join(dir, "netbird.debug.222.zip")
|
||||
other := filepath.Join(dir, "netbird.debug.333.txt")
|
||||
owned := filepath.Join(dir, "netbird.debug.444.zip")
|
||||
abandoned := filepath.Join(dir, "netbird.debug.555.zip")
|
||||
for _, p := range []string{stale, fresh, other, owned, abandoned} {
|
||||
require.NoError(t, os.WriteFile(p, []byte("x"), 0o600))
|
||||
}
|
||||
exported, err := ExportBundle(owned)
|
||||
require.NoError(t, err)
|
||||
exportedAbandoned, err := ExportBundle(abandoned)
|
||||
require.NoError(t, err)
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
for _, p := range []string{stale, other, exported} {
|
||||
require.NoError(t, os.Chtimes(p, old, old))
|
||||
}
|
||||
ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour)
|
||||
require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient))
|
||||
|
||||
RemoveStaleBundles(dir, time.Hour)
|
||||
|
||||
assert.NoFileExists(t, stale, "bundle older than maxAge should be removed")
|
||||
assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading")
|
||||
assert.FileExists(t, other, "files outside the bundle pattern must not be touched")
|
||||
assert.NoFileExists(t, owned)
|
||||
assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge")
|
||||
assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned")
|
||||
}
|
||||
|
||||
func TestBundleIncludesNetworkMap(t *testing.T) {
|
||||
for _, anonymize := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) {
|
||||
g := NewBundleGenerator(GeneratorDependencies{
|
||||
SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}},
|
||||
}, BundleConfig{Anonymize: anonymize})
|
||||
|
||||
require.Contains(t, bundleEntries(t, g), "network_map.json")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) {
|
||||
g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{})
|
||||
|
||||
require.NotContains(t, bundleEntries(t, g), "network_map.json")
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
@@ -24,6 +24,10 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// testIFaceBlackList mirrors the overlay prefixes profilemanager.DefaultInterfaceBlacklist
|
||||
// carries. Declared here rather than imported because profilemanager imports this package.
|
||||
var testIFaceBlackList = []string{"wt", "utun", "tun0"}
|
||||
|
||||
func TestUpdateDNSServer(t *testing.T) {
|
||||
|
||||
nameServers := []nbdns.NameServer{
|
||||
@@ -243,10 +247,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
for n, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
privKey, _ := wgtypes.GenerateKey()
|
||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: fmt.Sprintf("utun230%d", n),
|
||||
@@ -348,11 +349,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
|
||||
defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
|
||||
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
|
||||
if err != nil {
|
||||
t.Errorf("create stdnet: %v", err)
|
||||
return
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}, nil)
|
||||
|
||||
privKey, _ := wgtypes.GeneratePrivateKey()
|
||||
opts := iface.WGIFaceOpts{
|
||||
|
||||
@@ -394,11 +394,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) {
|
||||
defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
|
||||
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
|
||||
if err != nil {
|
||||
t.Fatalf("create stdnet: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}, nil)
|
||||
|
||||
privKey, _ := wgtypes.GeneratePrivateKey()
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build mips || mips64 || ppc64 || s390x
|
||||
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// loadBpf returns the embedded CollectionSpec for bpf.
|
||||
func loadBpf() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_BpfBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load bpf: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadBpfObjects loads bpf and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *bpfObjects
|
||||
// *bpfPrograms
|
||||
// *bpfMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadBpf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// bpfSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
bpfMapSpecs
|
||||
bpfVariableSpecs
|
||||
}
|
||||
|
||||
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfProgramSpecs struct {
|
||||
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
|
||||
}
|
||||
|
||||
// bpfMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfVariableSpecs struct {
|
||||
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
return _BpfClose(
|
||||
&o.bpfPrograms,
|
||||
&o.bpfMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbWgProxySettingsMap,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfVariables struct {
|
||||
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfPrograms struct {
|
||||
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
|
||||
}
|
||||
|
||||
func (p *bpfPrograms) Close() error {
|
||||
return _BpfClose(
|
||||
p.NbXdpProg,
|
||||
)
|
||||
}
|
||||
|
||||
func _BpfClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed bpf_bpfeb.o
|
||||
var _BpfBytes []byte
|
||||
Binary file not shown.
@@ -1,148 +0,0 @@
|
||||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
|
||||
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// loadBpf returns the embedded CollectionSpec for bpf.
|
||||
func loadBpf() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_BpfBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load bpf: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadBpfObjects loads bpf and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *bpfObjects
|
||||
// *bpfPrograms
|
||||
// *bpfMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadBpf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// bpfSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
bpfMapSpecs
|
||||
bpfVariableSpecs
|
||||
}
|
||||
|
||||
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfProgramSpecs struct {
|
||||
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
|
||||
}
|
||||
|
||||
// bpfMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfVariableSpecs struct {
|
||||
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
return _BpfClose(
|
||||
&o.bpfPrograms,
|
||||
&o.bpfMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbWgProxySettingsMap,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfVariables struct {
|
||||
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfPrograms struct {
|
||||
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
|
||||
}
|
||||
|
||||
func (p *bpfPrograms) Close() error {
|
||||
return _BpfClose(
|
||||
p.NbXdpProg,
|
||||
)
|
||||
}
|
||||
|
||||
func _BpfClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed bpf_bpfel.o
|
||||
var _BpfBytes []byte
|
||||
Binary file not shown.
@@ -1,115 +0,0 @@
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
const (
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
)
|
||||
|
||||
var (
|
||||
singleton manager.Manager
|
||||
singletonLock = &sync.Mutex{}
|
||||
)
|
||||
|
||||
// required packages libbpf-dev, libc6-dev-i386-amd64-cross
|
||||
|
||||
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
|
||||
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
|
||||
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
|
||||
//
|
||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
|
||||
type GeneralManager struct {
|
||||
lock sync.Mutex
|
||||
link link.Link
|
||||
featureFlags uint16
|
||||
bpfObjs bpfObjects
|
||||
}
|
||||
|
||||
// GetEbpfManagerInstance return a static eBpf Manager instance
|
||||
func GetEbpfManagerInstance() manager.Manager {
|
||||
singletonLock.Lock()
|
||||
defer singletonLock.Unlock()
|
||||
if singleton != nil {
|
||||
return singleton
|
||||
}
|
||||
singleton = &GeneralManager{}
|
||||
return singleton
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) setFeatureFlag(feature uint16) {
|
||||
tf.featureFlags |= feature
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) loadXdp() error {
|
||||
if tf.link != nil {
|
||||
return nil
|
||||
}
|
||||
// it required for Docker
|
||||
err := rlimit.RemoveMemlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
iFace, err := net.InterfaceByName("lo")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// load pre-compiled programs into the kernel.
|
||||
err = loadBpfObjects(&tf.bpfObjs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tf.link, err = link.AttachXDP(link.XDPOptions{
|
||||
Program: tf.bpfObjs.NbXdpProg,
|
||||
Interface: iFace.Index,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
_ = tf.bpfObjs.Close()
|
||||
tf.link = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) unsetFeatureFlag(feature uint16) error {
|
||||
tf.lock.Lock()
|
||||
defer tf.lock.Unlock()
|
||||
tf.featureFlags &^= feature
|
||||
|
||||
if tf.link == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tf.featureFlags == 0 {
|
||||
return tf.close()
|
||||
}
|
||||
|
||||
return tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) close() error {
|
||||
log.Debugf("detach ebpf program ")
|
||||
err := tf.bpfObjs.Close()
|
||||
if err != nil {
|
||||
log.Warnf("failed to close eBpf objects: %s", err)
|
||||
}
|
||||
|
||||
err = tf.link.Close()
|
||||
tf.link = nil
|
||||
return err
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManager_setFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("invalid feature state")
|
||||
}
|
||||
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_unsetFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
|
||||
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
if mgr.featureFlags != 0 {
|
||||
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
|
||||
// map definitions: they load on kernels built without BTF, which BTF-style
|
||||
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
|
||||
// programs compile against current libbpf headers.
|
||||
#ifndef NB_BPF_MAP_DEF_H
|
||||
#define NB_BPF_MAP_DEF_H
|
||||
|
||||
struct bpf_map_def {
|
||||
unsigned int type;
|
||||
unsigned int key_size;
|
||||
unsigned int value_size;
|
||||
unsigned int max_entries;
|
||||
unsigned int map_flags;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,54 +0,0 @@
|
||||
#include <stdbool.h>
|
||||
#include <linux/if_ether.h> // ETH_P_IP
|
||||
#include <linux/udp.h>
|
||||
#include <linux/ip.h>
|
||||
#include <netinet/in.h>
|
||||
#include <linux/bpf.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include "wg_proxy.c"
|
||||
|
||||
const __u16 flag_feature_wg_proxy = 0b01;
|
||||
|
||||
const __u32 map_key_features = 0;
|
||||
struct bpf_map_def SEC("maps") nb_features = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u16),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
SEC("xdp")
|
||||
int nb_xdp_prog(struct xdp_md *ctx) {
|
||||
__u16 *features;
|
||||
features = bpf_map_lookup_elem(&nb_features, &map_key_features);
|
||||
if (!features) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
void *data = (void *)(long)ctx->data;
|
||||
void *data_end = (void *)(long)ctx->data_end;
|
||||
struct ethhdr *eth = data;
|
||||
struct iphdr *ip = (data + sizeof(struct ethhdr));
|
||||
struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr));
|
||||
|
||||
// return early if not enough data
|
||||
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
// skip non IPv4 packages
|
||||
if (eth->h_proto != htons(ETH_P_IP)) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
// skip non UPD packages
|
||||
if (ip->protocol != IPPROTO_UDP) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (*features & flag_feature_wg_proxy) {
|
||||
xdp_wg_proxy(ip, udp);
|
||||
}
|
||||
return XDP_PASS;
|
||||
}
|
||||
char _license[] SEC("license") = "GPL";
|
||||
@@ -1,27 +0,0 @@
|
||||
# XDP programs
|
||||
|
||||
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
|
||||
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
|
||||
loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
|
||||
port instead, and swaps the peer endpoint port into the source so the proxy can tell
|
||||
peers apart.
|
||||
|
||||
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
|
||||
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
|
||||
definitions do not.
|
||||
|
||||
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
|
||||
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
|
||||
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
|
||||
|
||||
# Debug
|
||||
|
||||
The CONFIG_BPF_EVENTS kernel module is required for bpf_printk.
|
||||
Apply this code to use bpf_printk
|
||||
```
|
||||
#define bpf_printk(fmt, ...) \
|
||||
({ \
|
||||
char ____fmt[] = fmt; \
|
||||
bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
|
||||
})
|
||||
```
|
||||
@@ -1,60 +0,0 @@
|
||||
const __u32 map_key_proxy_port = 0;
|
||||
const __u32 map_key_wg_port = 1;
|
||||
|
||||
struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u16),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
__u16 proxy_port = 0;
|
||||
__u16 wg_port = 0;
|
||||
|
||||
bool read_port_settings() {
|
||||
__u16 *value;
|
||||
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
proxy_port = *value;
|
||||
|
||||
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
wg_port = htons(*value);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
|
||||
if (proxy_port == 0 || wg_port == 0) {
|
||||
if (!read_port_settings()){
|
||||
return XDP_PASS;
|
||||
}
|
||||
// bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port);
|
||||
}
|
||||
|
||||
// 2130706433 = 127.0.0.1
|
||||
if (ip->daddr != htonl(2130706433)) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source != wg_port){
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
__be16 new_src_port = udp->dest;
|
||||
__be16 new_dst_port = htons(proxy_port);
|
||||
udp->dest = new_dst_port;
|
||||
udp->source = new_src_port;
|
||||
|
||||
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
|
||||
// and the payload is already integrity-protected, so clear the checksum (a
|
||||
// zero UDP checksum means "not computed" for IPv4) rather than leave a
|
||||
// stale value the kernel would drop as UDP_CSUM.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package ebpf
|
||||
|
||||
import log "github.com/sirupsen/logrus"
|
||||
|
||||
const (
|
||||
mapKeyProxyPort uint32 = 0
|
||||
mapKeyWgPort uint32 = 1
|
||||
)
|
||||
|
||||
func (tf *GeneralManager) LoadWgProxy(proxyPort, wgPort int) error {
|
||||
log.Debugf("load ebpf WG proxy")
|
||||
tf.lock.Lock()
|
||||
defer tf.lock.Unlock()
|
||||
|
||||
err := tf.loadXdp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyProxyPort, uint16(proxyPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyWgPort, uint16(wgPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tf.setFeatureFlag(featureFlagWGProxy)
|
||||
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) FreeWGProxy() error {
|
||||
log.Debugf("free ebpf WG proxy")
|
||||
return tf.unsetFeatureFlag(featureFlagWGProxy)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !android
|
||||
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/ebpf"
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal
|
||||
// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant
|
||||
// panic on older Android version.
|
||||
func GetEbpfManagerInstance() manager.Manager {
|
||||
return ebpf.GetEbpfManagerInstance()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !linux || android
|
||||
|
||||
package ebpf
|
||||
|
||||
import "github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
|
||||
// GetEbpfManagerInstance return error because ebpf is not supported on all os
|
||||
func GetEbpfManagerInstance() manager.Manager {
|
||||
panic("unsupported os")
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package manager
|
||||
|
||||
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
|
||||
type Manager interface {
|
||||
LoadWgProxy(proxyPort, wgPort int) error
|
||||
FreeWGProxy() error
|
||||
}
|
||||
+14
-115
@@ -42,7 +42,6 @@ import (
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/netflow"
|
||||
@@ -58,6 +57,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
@@ -253,6 +253,9 @@ type Engine struct {
|
||||
|
||||
udpMux *udpmux.UniversalUDPMuxDefault
|
||||
|
||||
// wgDetector is shared by every ICE agent through the ICE config.
|
||||
wgDetector *stdnet.WGDetector
|
||||
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
networkSerial uint64
|
||||
|
||||
@@ -269,11 +272,10 @@ type Engine struct {
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
firewall firewallManager.Manager
|
||||
routeManager routemanager.Manager
|
||||
acl acl.Manager
|
||||
dnsForwardMgr *dnsfwd.Manager
|
||||
ingressGatewayMgr *ingressgw.Manager
|
||||
firewall firewallManager.Manager
|
||||
routeManager routemanager.Manager
|
||||
acl acl.Manager
|
||||
dnsForwardMgr *dnsfwd.Manager
|
||||
|
||||
dnsServer dns.Server
|
||||
|
||||
@@ -371,6 +373,7 @@ func NewEngine(
|
||||
mgmClient: services.MgmClient,
|
||||
relayManager: services.RelayManager,
|
||||
peerStore: peerstore.NewConnStore(),
|
||||
wgDetector: stdnet.NewWGDetector(),
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
@@ -455,13 +458,6 @@ func (e *Engine) stopLocked() {
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
if err := e.ingressGatewayMgr.Close(); err != nil {
|
||||
log.Warnf("failed to cleanup forward rules: %v", err)
|
||||
}
|
||||
e.ingressGatewayMgr = nil
|
||||
}
|
||||
|
||||
if e.srWatcher != nil {
|
||||
e.srWatcher.Close()
|
||||
}
|
||||
@@ -471,7 +467,7 @@ func (e *Engine) stopLocked() {
|
||||
}
|
||||
|
||||
if e.updateManager != nil {
|
||||
e.updateManager.SetDownloadOnly()
|
||||
e.updateManager.ResetMode()
|
||||
}
|
||||
|
||||
log.Info("cleaning up status recorder states")
|
||||
@@ -676,10 +672,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, peerConfig *mgmPro
|
||||
}
|
||||
e.wgDevice.Store(e.wgInterface.GetWGDevice())
|
||||
|
||||
// Set up notrack rules immediately after proxy is listening to prevent
|
||||
// conntrack entries from being created before the rules are in place
|
||||
e.setupWGProxyNoTrack()
|
||||
|
||||
// Start after interface is up since port may have been resolved from 0 or changed if occupied
|
||||
e.shutdownWg.Add(1)
|
||||
go func() {
|
||||
@@ -817,23 +809,6 @@ func (e *Engine) initFirewall() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupWGProxyNoTrack configures connection tracking exclusion for WireGuard proxy traffic.
|
||||
// This prevents conntrack/MASQUERADE from affecting loopback traffic between WireGuard and the eBPF proxy.
|
||||
func (e *Engine) setupWGProxyNoTrack() {
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
|
||||
proxyPort := e.wgInterface.GetProxyPort()
|
||||
if proxyPort == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.firewall.SetupEBPFProxyNoTrack(proxyPort, uint16(e.config.WgPort)); err != nil {
|
||||
log.Warnf("failed to setup ebpf proxy notrack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) blockLanAccess() {
|
||||
if e.config.BlockInbound {
|
||||
// no need to set up extra deny rules if inbound is already blocked in general
|
||||
@@ -996,11 +971,13 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
|
||||
}
|
||||
|
||||
if autoUpdateSettings == nil {
|
||||
log.Infof("no auto-update settings received, defaulting to download-only")
|
||||
e.updateManager.SetDownloadOnly()
|
||||
return
|
||||
}
|
||||
|
||||
if autoUpdateSettings.Version == disableAutoUpdate {
|
||||
log.Infof("auto-update is disabled")
|
||||
log.Infof("auto-update is disabled, switching to download-only")
|
||||
e.updateManager.SetDownloadOnly()
|
||||
return
|
||||
}
|
||||
@@ -1698,13 +1675,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
|
||||
done()
|
||||
|
||||
// Ingress forward rules
|
||||
done = e.phase("forward_rules")
|
||||
if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil {
|
||||
log.Errorf("failed to update forward rules, err: %v", err)
|
||||
}
|
||||
done()
|
||||
|
||||
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
|
||||
|
||||
done = e.phase("offline_peers")
|
||||
@@ -2249,10 +2219,7 @@ func (e *Engine) close() {
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
log.Errorf("failed to create pion's stdnet: %s", err)
|
||||
}
|
||||
transportNet := e.newStdNet()
|
||||
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: e.config.WgIfaceName,
|
||||
@@ -2807,74 +2774,6 @@ func (e *Engine) setForwarderCapture(pc device.PacketCapture) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) {
|
||||
if e.firewall == nil {
|
||||
log.Warn("firewall is disabled, not updating forwarding rules")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
if e.ingressGatewayMgr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := e.ingressGatewayMgr.Close()
|
||||
e.ingressGatewayMgr = nil
|
||||
e.statusRecorder.SetIngressGwMgr(nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.ingressGatewayMgr == nil {
|
||||
mgr := ingressgw.NewManager(e.firewall)
|
||||
e.ingressGatewayMgr = mgr
|
||||
e.statusRecorder.SetIngressGwMgr(mgr)
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol())
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
dstPortInfo, err := convertPortInfo(rule.GetDestinationPort())
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("invalid destination port '%v': %w", rule.GetDestinationPort(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
translateIP, err := convertToIP(rule.GetTranslatedAddress())
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("failed to convert translated address '%s': %w", rule.GetTranslatedAddress(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
translatePort, err := convertPortInfo(rule.GetTranslatedPort())
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("invalid translate port '%v': %w", rule.GetTranslatedPort(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
forwardRule := firewallManager.ForwardRule{
|
||||
Protocol: proto,
|
||||
DestinationPort: *dstPortInfo,
|
||||
TranslatedAddress: translateIP,
|
||||
TranslatedPort: *translatePort,
|
||||
}
|
||||
|
||||
forwardingRules = append(forwardingRules, forwardRule)
|
||||
}
|
||||
|
||||
log.Infof("updating forwarding rules: %d", len(forwardingRules))
|
||||
if err := e.ingressGatewayMgr.Update(forwardingRules); err != nil {
|
||||
log.Errorf("failed to update forwarding rules: %v", err)
|
||||
}
|
||||
|
||||
return forwardingRules, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// toExcludedLazyPeers returns the peers that must have an always-active
|
||||
// connection: those that are not lazy by policy (the per-peer lazy state or the
|
||||
// account flag, subject to the local override).
|
||||
|
||||
@@ -15,5 +15,6 @@ func (e *Engine) createICEConfig() icemaker.Config {
|
||||
UDPMux: e.udpMux.SingleSocketUDPMux,
|
||||
UDPMuxSrflx: e.udpMux,
|
||||
NATExternalIPs: e.parseNATExternalIPMappings(),
|
||||
WGDetector: e.wgDetector,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func (e *Engine) createICEConfig() icemaker.Config {
|
||||
InterfaceBlackList: e.config.IFaceBlackList,
|
||||
DisableIPv6Discovery: e.config.DisableIPv6Discovery,
|
||||
NATExternalIPs: e.parseNATExternalIPMappings(),
|
||||
WGDetector: e.wgDetector,
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -41,7 +42,6 @@ import (
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -81,6 +81,7 @@ func TestEngine_SSH(t *testing.T) {
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
|
||||
ServerSSHAllowed: true,
|
||||
MTU: iface.DefaultMTU,
|
||||
SSHKey: sshKey,
|
||||
@@ -204,11 +205,12 @@ func TestEngine_Sync(t *testing.T) {
|
||||
}
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun103",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
WgIfaceName: "utun103",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
|
||||
@@ -412,11 +414,12 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin
|
||||
|
||||
wgPort := 33100 + i
|
||||
conf := &EngineConfig{
|
||||
WgIfaceName: ifaceName,
|
||||
WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
|
||||
WgPrivateKey: key,
|
||||
WgPort: wgPort,
|
||||
MTU: iface.DefaultMTU,
|
||||
WgIfaceName: ifaceName,
|
||||
WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
|
||||
WgPrivateKey: key,
|
||||
WgPort: wgPort,
|
||||
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
|
||||
MTU: iface.DefaultMTU,
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
@@ -519,8 +522,8 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !js
|
||||
//go:build !js && !android
|
||||
|
||||
package internal
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// newSessionWatcher returns the real SSO session expiry watcher for every
|
||||
// non-wasm build. The js/wasm build gets a no-op stub from
|
||||
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
||||
// machinery) never links into the wasm binary.
|
||||
// newSessionWatcher returns the real SSO session expiry watcher. The js/wasm
|
||||
// build gets a no-op stub from engine_sessionwatch_js.go so the sessionwatch
|
||||
// package (and its timer machinery) never links into the wasm binary; the
|
||||
// android build gets a deadline-only watcher from
|
||||
// engine_sessionwatch_android.go because the app schedules the warnings
|
||||
// itself.
|
||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||
return sessionwatch.New(recorder)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||
return sessionwatch.NewDeadlineOnly(recorder)
|
||||
}
|
||||
@@ -6,6 +6,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
func (e *Engine) newStdNet() (*stdnet.Net, error) {
|
||||
return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList)
|
||||
func (e *Engine) newStdNet() *stdnet.Net {
|
||||
return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList, e.wgDetector)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ package internal
|
||||
|
||||
import "github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
|
||||
func (e *Engine) newStdNet() (*stdnet.Net, error) {
|
||||
return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList)
|
||||
func (e *Engine) newStdNet() *stdnet.Net {
|
||||
return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList, e.wgDetector)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ type MockWGIface struct {
|
||||
GetStatsFunc func() (map[string]configurer.WGStats, error)
|
||||
GetInterfaceGUIDStringFunc func() (string, error)
|
||||
GetProxyFunc func() wgproxy.Proxy
|
||||
GetProxyPortFunc func() uint16
|
||||
GetNetFunc func() *netstack.Net
|
||||
LastActivitiesFunc func() map[string]monotime.Time
|
||||
}
|
||||
@@ -162,13 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy {
|
||||
return m.GetProxyFunc()
|
||||
}
|
||||
|
||||
func (m *MockWGIface) GetProxyPort() uint16 {
|
||||
if m.GetProxyPortFunc != nil {
|
||||
return m.GetProxyPortFunc()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *MockWGIface) GetNet() *netstack.Net {
|
||||
return m.GetNetFunc()
|
||||
}
|
||||
@@ -696,10 +688,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
engine.ctx = ctx
|
||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
|
||||
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: wgIfaceName,
|
||||
@@ -904,10 +893,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
||||
}, MobileDependency{})
|
||||
engine.ctx = ctx
|
||||
|
||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: wgIfaceName,
|
||||
Address: wgaddr.MustParseWGAddress(wgAddr),
|
||||
@@ -1507,3 +1493,21 @@ func TestOverlayAddrsFromAllowedIPs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_SyncResponsePersistence(t *testing.T) {
|
||||
e := &Engine{}
|
||||
|
||||
_, err := e.GetLatestSyncResponse()
|
||||
require.Error(t, err, "persistence is disabled by default")
|
||||
|
||||
e.SetSyncResponsePersistence(true)
|
||||
e.persistSyncResponse(&mgmtProto.SyncResponse{NetworkMap: &mgmtProto.NetworkMap{Serial: 7}})
|
||||
|
||||
got, err := e.GetLatestSyncResponse()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(7), got.GetNetworkMap().GetSerial())
|
||||
|
||||
e.SetSyncResponsePersistence(false)
|
||||
_, err = e.GetLatestSyncResponse()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ type wgIfaceBase interface {
|
||||
Up() (*udpmux.UniversalUDPMuxDefault, error)
|
||||
UpdateAddr(newAddr wgaddr.Address) error
|
||||
GetProxy() wgproxy.Proxy
|
||||
GetProxyPort() uint16
|
||||
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
||||
RemoveEndpointAddress(key string) error
|
||||
RemovePeer(peerKey string) error
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package ingressgw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
)
|
||||
|
||||
type DNATFirewall interface {
|
||||
AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error)
|
||||
DeleteDNATRule(rule firewall.Rule) error
|
||||
}
|
||||
|
||||
type RulePair struct {
|
||||
firewall.ForwardRule
|
||||
firewall.Rule
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
dnatFirewall DNATFirewall
|
||||
|
||||
rules map[firewall.RuleID]RulePair
|
||||
rulesMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewManager(dnatFirewall DNATFirewall) *Manager {
|
||||
return &Manager{
|
||||
dnatFirewall: dnatFirewall,
|
||||
rules: make(map[firewall.RuleID]RulePair),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Manager) Update(forwardRules []firewall.ForwardRule) error {
|
||||
h.rulesMu.Lock()
|
||||
defer h.rulesMu.Unlock()
|
||||
|
||||
var mErr *multierror.Error
|
||||
|
||||
toDelete := make(map[firewall.RuleID]RulePair, len(h.rules))
|
||||
for id, r := range h.rules {
|
||||
toDelete[id] = r
|
||||
}
|
||||
|
||||
// Process new/updated rules
|
||||
for _, fwdRule := range forwardRules {
|
||||
id := fwdRule.ID()
|
||||
if _, ok := h.rules[id]; ok {
|
||||
delete(toDelete, id)
|
||||
continue
|
||||
}
|
||||
|
||||
rule, err := h.dnatFirewall.AddDNATRule(fwdRule)
|
||||
if err != nil {
|
||||
mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err))
|
||||
continue
|
||||
}
|
||||
if rule == nil {
|
||||
mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String()))
|
||||
continue
|
||||
}
|
||||
log.Infof("forward rule has been added '%s'", fwdRule)
|
||||
h.rules[id] = RulePair{
|
||||
ForwardRule: fwdRule,
|
||||
Rule: rule,
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted rules
|
||||
for id, rulePair := range toDelete {
|
||||
if err := h.dnatFirewall.DeleteDNATRule(rulePair.Rule); err != nil {
|
||||
mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rulePair.ForwardRule.String(), err))
|
||||
}
|
||||
log.Infof("forward rule has been deleted '%s'", rulePair.ForwardRule)
|
||||
delete(h.rules, id)
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(mErr)
|
||||
}
|
||||
|
||||
func (h *Manager) Close() error {
|
||||
h.rulesMu.Lock()
|
||||
defer h.rulesMu.Unlock()
|
||||
|
||||
log.Infof("clean up all (%d) forward rules", len(h.rules))
|
||||
var mErr *multierror.Error
|
||||
for _, rule := range h.rules {
|
||||
if err := h.dnatFirewall.DeleteDNATRule(rule.Rule); err != nil {
|
||||
mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rule, err))
|
||||
}
|
||||
}
|
||||
|
||||
h.rules = make(map[firewall.RuleID]RulePair)
|
||||
return nberrors.FormatErrorOrNil(mErr)
|
||||
}
|
||||
|
||||
func (h *Manager) Rules() []firewall.ForwardRule {
|
||||
h.rulesMu.Lock()
|
||||
defer h.rulesMu.Unlock()
|
||||
|
||||
rules := make([]firewall.ForwardRule, 0, len(h.rules))
|
||||
for _, rulePair := range h.rules {
|
||||
rules = append(rules, rulePair.ForwardRule)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package ingressgw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
)
|
||||
|
||||
var (
|
||||
_ firewall.Rule = (*MocFwRule)(nil)
|
||||
_ DNATFirewall = &MockDNATFirewall{}
|
||||
)
|
||||
|
||||
type MocFwRule struct {
|
||||
id firewall.RuleID
|
||||
}
|
||||
|
||||
func (m *MocFwRule) ID() firewall.RuleID {
|
||||
return m.id
|
||||
}
|
||||
|
||||
type MockDNATFirewall struct {
|
||||
throwError bool
|
||||
}
|
||||
|
||||
func (m *MockDNATFirewall) AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if m.throwError {
|
||||
return nil, fmt.Errorf("moc error")
|
||||
}
|
||||
|
||||
fwRule := &MocFwRule{
|
||||
id: fwdRule.ID(),
|
||||
}
|
||||
return fwRule, nil
|
||||
}
|
||||
|
||||
func (m *MockDNATFirewall) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if m.throwError {
|
||||
return fmt.Errorf("moc error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDNATFirewall) forceToThrowErrors() {
|
||||
m.throwError = true
|
||||
}
|
||||
|
||||
func TestManager_AddRule(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
|
||||
updates := []firewall.ForwardRule{
|
||||
{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
},
|
||||
{
|
||||
Protocol: firewall.ProtocolUDP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}}
|
||||
|
||||
if err := mgr.Update(updates); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != len(updates) {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_UpdateRule(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ruleUDP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolUDP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleUDP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 1 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
|
||||
if rules[0].TranslatedAddress.String() != ruleUDP.TranslatedAddress.String() {
|
||||
t.Errorf("unexpected rule: %v", rules[0])
|
||||
}
|
||||
|
||||
if rules[0].TranslatedPort.String() != ruleUDP.TranslatedPort.String() {
|
||||
t.Errorf("unexpected rule: %v", rules[0])
|
||||
}
|
||||
|
||||
if rules[0].DestinationPort.String() != ruleUDP.DestinationPort.String() {
|
||||
t.Errorf("unexpected rule: %v", rules[0])
|
||||
}
|
||||
|
||||
if rules[0].Protocol != ruleUDP.Protocol {
|
||||
t.Errorf("unexpected rule: %v", rules[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_ExtendRules(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
ruleUDP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolUDP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 2 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_UnderlingError(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
ruleUDP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolUDP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
fw.forceToThrowErrors()
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err == nil {
|
||||
t.Errorf("expected error")
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 1 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Cleanup(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 0 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_DeleteBrokenRule(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
|
||||
// force to throw errors when Add DNAT Rule
|
||||
fw.forceToThrowErrors()
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err == nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 0 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
|
||||
// simulate that to remove a broken rule
|
||||
if err := mgr.Update([]firewall.ForwardRule{}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := mgr.Close(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
fw := &MockDNATFirewall{}
|
||||
mgr := NewManager(fw)
|
||||
|
||||
port, _ := firewall.NewPort(8080)
|
||||
ruleTCP := firewall.ForwardRule{
|
||||
Protocol: firewall.ProtocolTCP,
|
||||
DestinationPort: *port,
|
||||
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
|
||||
TranslatedPort: *port,
|
||||
}
|
||||
|
||||
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := mgr.Close(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rules := mgr.Rules()
|
||||
if len(rules) != 0 {
|
||||
t.Errorf("unexpected rules count: %d", len(rules))
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func asDaemon(t *testing.T, id Identity) {
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
selfIdentity, selfKnown = id, true
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
selfMayDelegate = mayDelegate(id)
|
||||
}
|
||||
|
||||
func TestCallerIdentity_DirectConnections(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// Well-known Windows SIDs that identify a fully privileged principal.
|
||||
// Well-known Windows SIDs. Only LocalSystem and BUILTIN\Administrators identify a
|
||||
// privileged principal; the service accounts are shared by unrelated services.
|
||||
const (
|
||||
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
|
||||
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
|
||||
@@ -67,9 +68,9 @@ func (i Identity) IsWindows() bool {
|
||||
// user-to-root boundary.
|
||||
//
|
||||
// On Windows the decision comes from the caller's token rather than from
|
||||
// account names or group RIDs: an elevated token, one of the service accounts
|
||||
// the daemon itself may run as, or a token with BUILTIN\Administrators
|
||||
// enabled. A UAC-filtered administrator has that group marked deny-only, and
|
||||
// account names or group RIDs: an elevated token, the LocalSystem SID, or a
|
||||
// token with BUILTIN\Administrators enabled. LocalService and NetworkService
|
||||
// are not privileged by SID. A UAC-filtered administrator has that group marked deny-only, and
|
||||
// deny-only groups are dropped when the identity is captured, so such a
|
||||
// caller is correctly reported as unprivileged. Domain group memberships
|
||||
// (Domain Admins and friends) are deliberately not consulted: they say
|
||||
@@ -83,8 +84,7 @@ func (i Identity) IsPrivileged() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
switch i.SID {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService:
|
||||
if i.SID == sidLocalSystem {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+55
@@ -64,3 +64,58 @@ func TestIdentitySameUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityIsPrivileged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id Identity
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Root",
|
||||
id: Identity{UID: 0, GID: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Non-root",
|
||||
id: Identity{UID: 1000, GID: 1000},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Local system windows",
|
||||
id: Identity{SID: sidLocalSystem},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Windows elevated",
|
||||
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Elevated: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Admin group windows",
|
||||
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Groups: []string{sidAdministrators}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Regular user windows",
|
||||
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Network service windows",
|
||||
id: Identity{SID: sidNetworkService},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Local service windows",
|
||||
id: Identity{SID: sidLocalService},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.id.IsPrivileged())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,15 @@ func init() {
|
||||
// matching there would let a non-elevated shell of an administrator account
|
||||
// act as an administrator, which is the boundary the token check exists to
|
||||
// keep.
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
selfMayDelegate = mayDelegate(id)
|
||||
}
|
||||
|
||||
// mayDelegate reports whether a daemon running as id may extend its authority to
|
||||
// callers sharing its identity. The shared service accounts are excluded: their
|
||||
// SID is held by unrelated services, so matching on it would grant them the
|
||||
// daemon's authority.
|
||||
func mayDelegate(id Identity) bool {
|
||||
return !id.IsPrivileged() && id.SID != sidLocalService && id.SID != sidNetworkService
|
||||
}
|
||||
|
||||
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
|
||||
selfIdentity, selfKnown = tt.self, tt.selfKnown
|
||||
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
|
||||
selfMayDelegate = tt.selfKnown && mayDelegate(tt.self)
|
||||
|
||||
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
|
||||
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
|
||||
@@ -132,3 +132,28 @@ func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
|
||||
t.Errorf("an unrelated identity %v was treated as privileged", other)
|
||||
}
|
||||
}
|
||||
|
||||
// The shared service accounts are held by unrelated services, so a daemon running
|
||||
// as one of them must not extend its authority to every process with that SID.
|
||||
func TestMayDelegate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
self Identity
|
||||
want bool
|
||||
}{
|
||||
{name: "unprivileged unix user", self: Identity{UID: 1000}, want: true},
|
||||
{name: "root", self: Identity{UID: 0}, want: false},
|
||||
{name: "unprivileged windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001"}, want: true},
|
||||
{name: "elevated windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, want: false},
|
||||
{name: "local system", self: Identity{SID: sidLocalSystem}, want: false},
|
||||
{name: "local service", self: Identity{SID: sidLocalService}, want: false},
|
||||
{name: "network service", self: Identity{SID: sidNetworkService}, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := mayDelegate(tt.self); got != tt.want {
|
||||
t.Errorf("mayDelegate(%+v) = %v, want %v", tt.self, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) {
|
||||
if portInfo == nil {
|
||||
return nil, errors.New("portInfo cannot be nil")
|
||||
}
|
||||
|
||||
if portInfo.GetPort() != 0 {
|
||||
return firewallManager.NewPort(int(portInfo.GetPort()))
|
||||
}
|
||||
|
||||
if portInfo.GetRange() != nil {
|
||||
return firewallManager.NewPort(int(portInfo.GetRange().Start), int(portInfo.GetRange().End))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid portInfo: %v", portInfo)
|
||||
}
|
||||
|
||||
func convertToIP(rawIP []byte) (netip.Addr, error) {
|
||||
if rawIP == nil {
|
||||
return netip.Addr{}, errors.New("input bytes cannot be nil")
|
||||
}
|
||||
|
||||
if len(rawIP) != net.IPv4len && len(rawIP) != net.IPv6len {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IP length: %d", len(rawIP))
|
||||
}
|
||||
|
||||
if len(rawIP) == net.IPv4len {
|
||||
return netip.AddrFrom4([4]byte(rawIP)), nil
|
||||
}
|
||||
|
||||
return netip.AddrFrom16([16]byte(rawIP)), nil
|
||||
}
|
||||
@@ -828,7 +828,8 @@ func (conn *Conn) evalStatus() ConnStatus {
|
||||
//
|
||||
// The result is a tri-state:
|
||||
// - ConnStatusConnected: all available transports are up
|
||||
// - ConnStatusPartiallyConnected: relay is up but ICE is still pending/reconnecting
|
||||
// - ConnStatusPartiallyConnected: one transport carries the traffic and the other does
|
||||
// not: relay up with ICE down, or ICE up with the shared relay transport down
|
||||
// - ConnStatusDisconnected: no working transport
|
||||
func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
||||
defer func() {
|
||||
@@ -845,13 +846,14 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
||||
}
|
||||
|
||||
return evalConnStatus(connStatusInputs{
|
||||
forceRelay: IsForceRelayed(),
|
||||
peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(),
|
||||
relayConnected: conn.statusRelay.Get() == worker.StatusConnected,
|
||||
remoteSupportsICE: conn.handshaker.RemoteICESupported(),
|
||||
iceWorkerCreated: iceWorkerCreated,
|
||||
iceStatusConnecting: conn.statusICE.Get() != worker.StatusDisconnected,
|
||||
iceInProgress: iceInProgress,
|
||||
forceRelay: IsForceRelayed(),
|
||||
peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(),
|
||||
relayConnected: conn.statusRelay.Get() == worker.StatusConnected,
|
||||
relayTransportConnected: conn.workerRelay.IsTransportConnected(),
|
||||
remoteSupportsICE: conn.handshaker.RemoteICESupported(),
|
||||
iceWorkerCreated: iceWorkerCreated,
|
||||
iceStatusConnected: conn.statusICE.Get() == worker.StatusConnected,
|
||||
iceInProgress: iceInProgress,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1060,19 +1062,21 @@ func evalConnStatus(in connStatusInputs) guard.ConnStatus {
|
||||
return boolToConnStatus(relayUsedAndUp)
|
||||
}
|
||||
|
||||
// ICE counts as "up" when the status is anything other than Disconnected, OR
|
||||
// when a negotiation is currently in progress (so we don't spam offers while one is in flight).
|
||||
iceUp := in.iceStatusConnecting || in.iceInProgress
|
||||
// ICE counts as "running" when either connected or attempting to connect.
|
||||
iceRunning := in.iceStatusConnected || in.iceInProgress
|
||||
|
||||
// Relay side is acceptable if the peer doesn't rely on relay, or relay is connected.
|
||||
relayOK := !in.peerUsesRelay || in.relayConnected
|
||||
|
||||
switch {
|
||||
case iceUp && relayOK:
|
||||
case iceRunning && relayOK:
|
||||
return guard.ConnStatusConnected
|
||||
case relayUsedAndUp:
|
||||
// Relay is up but ICE is down — partially connected.
|
||||
return guard.ConnStatusPartiallyConnected
|
||||
case in.iceStatusConnected && !in.relayTransportConnected:
|
||||
// ICE is up and the shared relay transport is down — offers cannot restore it.
|
||||
return guard.ConnStatusPartiallyConnected
|
||||
default:
|
||||
return guard.ConnStatusDisconnected
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ const (
|
||||
// tri-state connection classification. Extracted so the decision logic can be unit-tested
|
||||
// without constructing full Worker/Handshaker objects.
|
||||
type connStatusInputs struct {
|
||||
forceRelay bool // NB_FORCE_RELAY or JS/WASM
|
||||
peerUsesRelay bool // remote peer advertises relay support AND local has relay
|
||||
relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay)
|
||||
remoteSupportsICE bool // remote peer sent ICE credentials
|
||||
iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode)
|
||||
iceStatusConnecting bool // statusICE is anything other than Disconnected
|
||||
iceInProgress bool // a negotiation is currently in flight
|
||||
forceRelay bool // NB_FORCE_RELAY or JS/WASM
|
||||
peerUsesRelay bool // remote peer advertises relay support AND local has relay
|
||||
relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay)
|
||||
relayTransportConnected bool // the relay transport shared by all peers on that server is up
|
||||
remoteSupportsICE bool // remote peer sent ICE credentials
|
||||
iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode)
|
||||
iceStatusConnected bool // statusICE reports Connected
|
||||
iceInProgress bool // a negotiation is currently in flight
|
||||
}
|
||||
|
||||
// ConnStatus describe the status of a peer's connection
|
||||
|
||||
@@ -30,6 +30,21 @@ func TestEvalConnStatus_ForceRelay(t *testing.T) {
|
||||
},
|
||||
want: guard.ConnStatusDisconnected,
|
||||
},
|
||||
{
|
||||
name: "force relay, relay up but the shared transport reports down",
|
||||
in: connStatusInputs{
|
||||
forceRelay: true,
|
||||
peerUsesRelay: true,
|
||||
relayConnected: true,
|
||||
relayTransportConnected: false,
|
||||
// The ICE inputs are set so that the force-relay return is the only branch
|
||||
// that can produce Connected here: without it the peer would fall through to
|
||||
// relayUsedAndUp and report PartiallyConnected.
|
||||
remoteSupportsICE: true,
|
||||
iceWorkerCreated: true,
|
||||
},
|
||||
want: guard.ConnStatusConnected,
|
||||
},
|
||||
{
|
||||
name: "force relay, peer does NOT use relay - disconnected forever",
|
||||
in: connStatusInputs{
|
||||
@@ -123,24 +138,28 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) {
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = true
|
||||
in.iceStatusConnecting = true
|
||||
in.relayTransportConnected = true
|
||||
in.iceStatusConnected = true
|
||||
},
|
||||
want: guard.ConnStatusConnected,
|
||||
},
|
||||
{
|
||||
name: "ICE connected, peer does NOT use relay",
|
||||
name: "ICE connected, peer does NOT use relay, shared transport down",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = false
|
||||
in.relayConnected = false
|
||||
in.iceStatusConnecting = true
|
||||
in.relayTransportConnected = false
|
||||
in.iceStatusConnected = true
|
||||
},
|
||||
// A peer that does not rely on relay is unaffected by the shared transport:
|
||||
// relayOK is true, so the first arm matches before the transport is considered.
|
||||
want: guard.ConnStatusConnected,
|
||||
},
|
||||
{
|
||||
name: "ICE InProgress only, peer does NOT use relay",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = false
|
||||
in.iceStatusConnecting = false
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = true
|
||||
},
|
||||
want: guard.ConnStatusConnected,
|
||||
@@ -150,7 +169,8 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) {
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = true
|
||||
in.iceStatusConnecting = false
|
||||
in.relayTransportConnected = true
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = false
|
||||
},
|
||||
want: guard.ConnStatusPartiallyConnected,
|
||||
@@ -160,21 +180,60 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) {
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = false
|
||||
in.relayConnected = false
|
||||
in.iceStatusConnecting = false
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = false
|
||||
},
|
||||
want: guard.ConnStatusDisconnected,
|
||||
},
|
||||
{
|
||||
name: "ICE up, peer uses relay but relay down -> partial (relay required, ICE ignored)",
|
||||
name: "ICE connected, relay down for this peer but the shared transport is up -> disconnected",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = false
|
||||
in.iceStatusConnecting = true
|
||||
in.relayTransportConnected = true
|
||||
in.iceStatusConnected = true
|
||||
},
|
||||
// The transport is fine, so the peer itself is unreachable over relay: it may have
|
||||
// moved to another server, and only an offer carries its new relay address.
|
||||
want: guard.ConnStatusDisconnected,
|
||||
},
|
||||
{
|
||||
name: "ICE connected, the shared relay transport is down -> partial",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = false
|
||||
in.relayTransportConnected = false
|
||||
in.iceStatusConnected = true
|
||||
},
|
||||
// ICE carries the traffic and the relay transport is restored by the relay client's
|
||||
// own guard, not by offers, so this must not trigger the aggressive retry.
|
||||
want: guard.ConnStatusPartiallyConnected,
|
||||
},
|
||||
{
|
||||
name: "ICE only negotiating while the shared relay transport is down -> disconnected",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = false
|
||||
in.relayTransportConnected = false
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = true
|
||||
},
|
||||
// A negotiation in flight is not a working transport, so this peer has no path at
|
||||
// all and must keep the aggressive retry. Calling it partially connected spends the
|
||||
// ICE retry budget and parks the guard on the hourly ticker, and nothing wakes it
|
||||
// when the negotiation then fails: onICEStateDisconnected is only reached once ICE
|
||||
// has reached Connected (worker_ice.go onConnectionStateChange).
|
||||
want: guard.ConnStatusDisconnected,
|
||||
},
|
||||
{
|
||||
name: "ICE down and the shared relay transport is down -> disconnected",
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = true
|
||||
in.relayConnected = false
|
||||
in.relayTransportConnected = false
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = false
|
||||
},
|
||||
// relayOK = false (peer uses relay but it's down), iceUp = true
|
||||
// first switch arm fails (relayOK false), relayUsedAndUp = false (relay down),
|
||||
// falls into default: Disconnected.
|
||||
want: guard.ConnStatusDisconnected,
|
||||
},
|
||||
{
|
||||
@@ -182,7 +241,7 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) {
|
||||
mutator: func(in *connStatusInputs) {
|
||||
in.peerUsesRelay = false
|
||||
in.relayConnected = true // not actually used since peer doesn't rely on it
|
||||
in.iceStatusConnecting = false
|
||||
in.iceStatusConnected = false
|
||||
in.iceInProgress = false
|
||||
},
|
||||
want: guard.ConnStatusDisconnected,
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestNewConn_interfaceFilter(t *testing.T) {
|
||||
ignore := []string{iface.WgInterfaceDefault, "tun0", "zt", "ZeroTier", "utun", "wg", "ts",
|
||||
"Tailscale", "tailscale"}
|
||||
|
||||
filter := stdnet.InterfaceFilter(ignore)
|
||||
filter := stdnet.InterfaceFilter(ignore, nil)
|
||||
|
||||
for _, s := range ignore {
|
||||
assert.Equal(t, filter(s), false)
|
||||
|
||||
@@ -14,7 +14,8 @@ type ConnStatus int
|
||||
const (
|
||||
// ConnStatusDisconnected means neither ICE nor Relay is connected.
|
||||
ConnStatusDisconnected ConnStatus = iota
|
||||
// ConnStatusPartiallyConnected means Relay is connected but ICE is not.
|
||||
// ConnStatusPartiallyConnected means one transport is usable and the other is not:
|
||||
// relay connected with ICE down, or ICE connected with the shared relay transport down.
|
||||
ConnStatusPartiallyConnected
|
||||
// ConnStatusConnected means all required connections are established.
|
||||
ConnStatusConnected
|
||||
@@ -87,8 +88,9 @@ func (g *Guard) SetICEConnDisconnected() {
|
||||
// - Connected: no action, the peer is fully reachable.
|
||||
// - Disconnected (neither ICE nor Relay): retries aggressively with exponential backoff (800ms doubling
|
||||
// up to timeout), never gives up. This ensures rapid recovery when the peer has no connectivity at all.
|
||||
// - PartiallyConnected (Relay up, ICE not): retries up to 3 times with exponential backoff, then switches
|
||||
// to one attempt per hour. This limits signaling traffic when relay already provides connectivity.
|
||||
// - PartiallyConnected (one transport usable, the other not): retries up to 3 times
|
||||
// with exponential backoff, then switches to one attempt per hour. This limits
|
||||
// signaling traffic while the peer still has a working path.
|
||||
//
|
||||
// External events (relay/ICE disconnect, signal/relay reconnect, candidate changes) reset the retry
|
||||
// counter and backoff ticker, giving ICE a fresh chance after network conditions change.
|
||||
|
||||
@@ -39,10 +39,7 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c
|
||||
iceFailedTimeout := iceFailedTimeout()
|
||||
iceRelayAcceptanceMinWait := iceRelayAcceptanceMinWait()
|
||||
|
||||
transportNet, err := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create pion's stdnet: %s", err)
|
||||
}
|
||||
transportNet := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList, config.WGDetector)
|
||||
|
||||
fac := logging.NewDefaultLoggerFactory()
|
||||
|
||||
@@ -53,7 +50,7 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c
|
||||
NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4, ice.NetworkTypeUDP6},
|
||||
Urls: config.StunTurn.Load(),
|
||||
CandidateTypes: candidateTypes,
|
||||
InterfaceFilter: stdnet.InterfaceFilter(config.InterfaceBlackList),
|
||||
InterfaceFilter: stdnet.InterfaceFilter(config.InterfaceBlackList, config.WGDetector),
|
||||
UDPMux: config.UDPMux,
|
||||
UDPMuxSrflx: config.UDPMuxSrflx,
|
||||
NAT1To1IPs: config.NATExternalIPs,
|
||||
|
||||
@@ -2,6 +2,8 @@ package ice
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -17,4 +19,8 @@ type Config struct {
|
||||
UDPMuxSrflx ice.UniversalUDPMux
|
||||
|
||||
NATExternalIPs []string
|
||||
|
||||
// WGDetector is shared by every agent so that the WireGuard check the interface
|
||||
// filter performs is not repeated for each of them.
|
||||
WGDetector *stdnet.WGDetector
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) {
|
||||
return stdnet.NewNet(ctx, ifaceBlacklist)
|
||||
func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string, detector *stdnet.WGDetector) *stdnet.Net {
|
||||
return stdnet.NewNet(ctx, ifaceBlacklist, detector)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) {
|
||||
return stdnet.NewNetWithDiscover(ctx, iFaceDiscover, ifaceBlacklist)
|
||||
func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string, detector *stdnet.WGDetector) *stdnet.Net {
|
||||
return stdnet.NewNetWithDiscover(ctx, iFaceDiscover, ifaceBlacklist, detector)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ type notifier struct {
|
||||
serverStateLock sync.Mutex
|
||||
listenersLock sync.Mutex
|
||||
listener Listener
|
||||
peerListWake chan struct{}
|
||||
peerListStop chan struct{}
|
||||
currentClientState bool
|
||||
lastNotification ClientState
|
||||
lastNumberOfPeers int
|
||||
@@ -62,7 +64,6 @@ func (n *notifier) setNetworkAvailable(available bool) {
|
||||
func (n *notifier) setListener(listener Listener) {
|
||||
n.serverStateLock.Lock()
|
||||
lastNotification := n.effectiveState(n.lastNotification)
|
||||
numOfPeers := n.lastNumberOfPeers
|
||||
fqdnAddress := n.lastFqdnAddress
|
||||
address := n.lastIPAddress
|
||||
n.serverStateLock.Unlock()
|
||||
@@ -70,17 +71,19 @@ func (n *notifier) setListener(listener Listener) {
|
||||
n.listenersLock.Lock()
|
||||
defer n.listenersLock.Unlock()
|
||||
|
||||
n.stopPeerListDelivererLocked()
|
||||
n.listener = listener
|
||||
|
||||
listener.OnAddressChanged(fqdnAddress, address)
|
||||
notifyListener(listener, lastNotification)
|
||||
// run on go routine to avoid on Java layer to call go functions on same thread
|
||||
go listener.OnPeersListChanged(numOfPeers)
|
||||
n.startPeerListDelivererLocked(listener)
|
||||
n.wakePeerListDelivererLocked()
|
||||
}
|
||||
|
||||
func (n *notifier) removeListener() {
|
||||
n.listenersLock.Lock()
|
||||
defer n.listenersLock.Unlock()
|
||||
n.stopPeerListDelivererLocked()
|
||||
n.listener = nil
|
||||
}
|
||||
|
||||
@@ -178,15 +181,56 @@ func (n *notifier) peerListChanged(numOfPeers int) {
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.listenersLock.Lock()
|
||||
listener := n.listener
|
||||
n.listenersLock.Unlock()
|
||||
defer n.listenersLock.Unlock()
|
||||
n.wakePeerListDelivererLocked()
|
||||
}
|
||||
|
||||
if listener == nil {
|
||||
func (n *notifier) startPeerListDelivererLocked(listener Listener) {
|
||||
wake := make(chan struct{}, 1)
|
||||
stop := make(chan struct{})
|
||||
n.peerListWake = wake
|
||||
n.peerListStop = stop
|
||||
go n.deliverPeerListChanges(listener, wake, stop)
|
||||
}
|
||||
|
||||
func (n *notifier) stopPeerListDelivererLocked() {
|
||||
if n.peerListStop == nil {
|
||||
return
|
||||
}
|
||||
close(n.peerListStop)
|
||||
n.peerListStop = nil
|
||||
n.peerListWake = nil
|
||||
}
|
||||
|
||||
// run on go routine to avoid on Java layer to call go functions on same thread
|
||||
go listener.OnPeersListChanged(numOfPeers)
|
||||
func (n *notifier) wakePeerListDelivererLocked() {
|
||||
if n.peerListWake == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case n.peerListWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) deliverPeerListChanges(listener Listener, wake <-chan struct{}, stop <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-wake:
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
numOfPeers := n.lastNumberOfPeers
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
listener.OnPeersListChanged(numOfPeers)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) localAddressChanged(fqdn, address string) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mocListener struct {
|
||||
@@ -115,3 +117,156 @@ func Test_notifier_RemoveListener(t *testing.T) {
|
||||
t.Errorf("invalid state: %d", listener.peers)
|
||||
}
|
||||
}
|
||||
|
||||
type coalescingListener struct {
|
||||
final int
|
||||
calls atomic.Int32
|
||||
inFlight atomic.Int32
|
||||
maxInFlight atomic.Int32
|
||||
last atomic.Int32
|
||||
done chan struct{}
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (l *coalescingListener) OnStateChanged(ClientState) {}
|
||||
func (l *coalescingListener) OnConnected() {}
|
||||
func (l *coalescingListener) OnDisconnected() {}
|
||||
func (l *coalescingListener) OnConnecting() {}
|
||||
func (l *coalescingListener) OnDisconnecting() {}
|
||||
func (l *coalescingListener) OnAddressChanged(string, string) {}
|
||||
|
||||
func (l *coalescingListener) OnPeersListChanged(size int) {
|
||||
current := l.inFlight.Add(1)
|
||||
for {
|
||||
seen := l.maxInFlight.Load()
|
||||
if current <= seen || l.maxInFlight.CompareAndSwap(seen, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if l.calls.Add(1) == 1 && l.entered != nil {
|
||||
close(l.entered)
|
||||
}
|
||||
if l.release != nil {
|
||||
<-l.release
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
l.last.Store(int32(size))
|
||||
l.inFlight.Add(-1)
|
||||
if size == l.final {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
}
|
||||
}
|
||||
|
||||
func Test_notifier_PeerListChangedCoalesces(t *testing.T) {
|
||||
const events = 1000
|
||||
listener := &coalescingListener{final: events, done: make(chan struct{})}
|
||||
n := newNotifier()
|
||||
n.setListener(listener)
|
||||
|
||||
for i := 1; i <= events; i++ {
|
||||
n.peerListChanged(i)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-listener.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("last peer count not delivered, last seen: %d", listener.last.Load())
|
||||
}
|
||||
|
||||
if got := listener.maxInFlight.Load(); got != 1 {
|
||||
t.Errorf("concurrent deliveries: %d, expected 1", got)
|
||||
}
|
||||
if got := listener.calls.Load(); got >= events {
|
||||
t.Errorf("deliveries not coalesced: %d calls for %d events", got, events)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_notifier_SetListenerStopsPreviousDeliverer(t *testing.T) {
|
||||
old := &coalescingListener{final: -1}
|
||||
replacement := &coalescingListener{final: 7, done: make(chan struct{})}
|
||||
n := newNotifier()
|
||||
n.setListener(old)
|
||||
oldStop := n.peerListStop
|
||||
|
||||
n.peerListChanged(7)
|
||||
n.setListener(replacement)
|
||||
|
||||
select {
|
||||
case <-oldStop:
|
||||
default:
|
||||
t.Fatal("old deliverer not stopped on listener replacement")
|
||||
}
|
||||
waitFor(t, replacement.done, "replacement listener not notified")
|
||||
}
|
||||
|
||||
func Test_notifier_RemoveListenerStopsDeliverer(t *testing.T) {
|
||||
n := newNotifier()
|
||||
n.setListener(&coalescingListener{final: -1})
|
||||
stop := n.peerListStop
|
||||
|
||||
n.removeListener()
|
||||
|
||||
select {
|
||||
case <-stop:
|
||||
default:
|
||||
t.Fatal("deliverer not stopped on listener removal")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_notifier_DelivererExitsAfterInFlightCallback(t *testing.T) {
|
||||
listener := &coalescingListener{
|
||||
final: -1,
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
n := newNotifier()
|
||||
wake := make(chan struct{}, 1)
|
||||
stop := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
n.deliverPeerListChanges(listener, wake, stop)
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
wake <- struct{}{}
|
||||
waitFor(t, listener.entered, "listener not called")
|
||||
|
||||
n.peerListChanged(7)
|
||||
wake <- struct{}{}
|
||||
close(stop)
|
||||
close(listener.release)
|
||||
|
||||
waitFor(t, exited, "deliverer did not exit after stop")
|
||||
if got := listener.calls.Load(); got != 1 {
|
||||
t.Errorf("deliverer ran %d callbacks after stop, expected only the in-flight one", got)
|
||||
}
|
||||
if got := listener.last.Load(); got == 7 {
|
||||
t.Errorf("deliverer delivered the peer count queued after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_notifier_DelivererPrefersStopOverPendingWake(t *testing.T) {
|
||||
listener := &coalescingListener{final: -1}
|
||||
n := newNotifier()
|
||||
wake := make(chan struct{}, 1)
|
||||
stop := make(chan struct{})
|
||||
|
||||
wake <- struct{}{}
|
||||
close(stop)
|
||||
n.deliverPeerListChanges(listener, wake, stop)
|
||||
|
||||
if got := listener.calls.Load(); got != 0 {
|
||||
t.Errorf("deliverer ran %d callbacks with stop closed, expected 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, ch <-chan struct{}, msg string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,7 @@ import (
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -161,7 +159,6 @@ type FullStatus struct {
|
||||
RosenpassState RosenpassState
|
||||
Relays []relay.ProbeResult
|
||||
NSGroupStates []NSGroupState
|
||||
NumOfForwardingRules int
|
||||
LazyConnectionEnabled bool
|
||||
Events []*proto.SystemEvent
|
||||
}
|
||||
@@ -196,6 +193,7 @@ type Status struct {
|
||||
muxRelays sync.RWMutex
|
||||
peers map[string]State
|
||||
ipToKey map[string]string
|
||||
activeRoutePeers map[route.HAUniqueID]string
|
||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||
signalState bool
|
||||
signalError error
|
||||
@@ -246,8 +244,6 @@ type Status struct {
|
||||
// read it without taking mux.
|
||||
networksRevision atomic.Uint64
|
||||
|
||||
ingressGwMgr *ingressgw.Manager
|
||||
|
||||
routeIDLookup routeIDLookup
|
||||
wgIface WGIfaceStatus
|
||||
}
|
||||
@@ -257,6 +253,7 @@ func NewRecorder(mgmAddress string) *Status {
|
||||
return &Status{
|
||||
peers: make(map[string]State),
|
||||
ipToKey: make(map[string]string),
|
||||
activeRoutePeers: make(map[route.HAUniqueID]string),
|
||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||
eventQueue: NewEventQueue(eventQueueSize),
|
||||
@@ -274,12 +271,6 @@ func (d *Status) SetRelayMgr(manager *relayClient.Manager) {
|
||||
d.relayMgr = manager
|
||||
}
|
||||
|
||||
func (d *Status) SetIngressGwMgr(ingressGwMgr *ingressgw.Manager) {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
d.ingressGwMgr = ingressGwMgr
|
||||
}
|
||||
|
||||
// ReplaceOfflinePeers replaces
|
||||
func (d *Status) ReplaceOfflinePeers(replacement []State) {
|
||||
d.mux.Lock()
|
||||
@@ -330,18 +321,6 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
for _, state := range d.peers {
|
||||
if state.IP == ip {
|
||||
return state.FQDN, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||
// address so dual-stack peers are reachable on either family. Only
|
||||
@@ -481,6 +460,24 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Status) AddActiveRoutePeer(haID route.HAUniqueID, peer string) {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
d.activeRoutePeers[haID] = peer
|
||||
}
|
||||
|
||||
func (d *Status) RemoveActiveRoutePeer(haID route.HAUniqueID) {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
delete(d.activeRoutePeers, haID)
|
||||
}
|
||||
|
||||
func (d *Status) GetActiveRoutePeers() map[route.HAUniqueID]string {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
return maps.Clone(d.activeRoutePeers)
|
||||
}
|
||||
|
||||
// CheckRoutes checks if the source and destination addresses are within the same route
|
||||
// and returns the resource ID of the route that contains the addresses
|
||||
func (d *Status) CheckRoutes(ip netip.Addr) ([]byte, bool) {
|
||||
@@ -1143,16 +1140,6 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||
return relayStates
|
||||
}
|
||||
|
||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
if d.ingressGwMgr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.ingressGwMgr.Rules()
|
||||
}
|
||||
|
||||
func (d *Status) GetDNSStates() []NSGroupState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
@@ -1187,7 +1174,6 @@ func (d *Status) GetFullStatus() FullStatus {
|
||||
Relays: d.GetRelayStates(),
|
||||
RosenpassState: d.GetRosenpassState(),
|
||||
NSGroupStates: d.GetDNSStates(),
|
||||
NumOfForwardingRules: len(d.ForwardingRules()),
|
||||
LazyConnectionEnabled: d.GetLazyConnection(),
|
||||
}
|
||||
|
||||
@@ -1559,7 +1545,6 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled
|
||||
|
||||
pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func TestAddPeer(t *testing.T) {
|
||||
@@ -372,3 +374,24 @@ func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) {
|
||||
status.MarkManagementDisconnected(err)
|
||||
assert.False(t, notified(ch), "redundant disconnect should not notify")
|
||||
}
|
||||
|
||||
func TestActiveRoutePeers(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
netA := route.HAUniqueID("net-a-10.0.0.0/24")
|
||||
netB := route.HAUniqueID("net-b-10.0.0.0/24")
|
||||
|
||||
status.AddActiveRoutePeer(netA, "peerA")
|
||||
status.AddActiveRoutePeer(netB, "peerB")
|
||||
|
||||
active := status.GetActiveRoutePeers()
|
||||
assert.Equal(t, "peerA", active[netA])
|
||||
assert.Equal(t, "peerB", active[netB])
|
||||
|
||||
status.RemoveActiveRoutePeer(netA)
|
||||
delete(active, netB)
|
||||
|
||||
active = status.GetActiveRoutePeers()
|
||||
_, ok := active[netA]
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "peerB", active[netB])
|
||||
}
|
||||
|
||||
@@ -121,11 +121,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
w.sessionID = sessionID
|
||||
// Keep the ID already advertised to the remote. Answers do not get a
|
||||
// reply, so changing it here makes the next offer restart both sides.
|
||||
w.abandonNegotiation()
|
||||
}
|
||||
|
||||
@@ -205,6 +202,9 @@ func (w *WorkerICE) Close() {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
|
||||
if w.agent != nil || w.agentConnecting {
|
||||
w.renewSessionID()
|
||||
}
|
||||
if w.agent != nil {
|
||||
w.agentDialerCancel()
|
||||
if err := w.agent.Close(); err != nil {
|
||||
@@ -366,16 +366,23 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
|
||||
// Only the owner of the current session may reset its state: a stale dial
|
||||
// goroutine waking after a newer attempt must not clobber it.
|
||||
if w.agent == agent {
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
w.sessionID = sessionID
|
||||
w.renewSessionID()
|
||||
w.abandonNegotiation()
|
||||
}
|
||||
return sessionChanged
|
||||
}
|
||||
|
||||
// renewSessionID starts a new local session, so the remote treats our next offer
|
||||
// or answer as a restart. Caller holds muxAgent.
|
||||
func (w *WorkerICE) renewSessionID() {
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
return
|
||||
}
|
||||
w.sessionID = sessionID
|
||||
}
|
||||
|
||||
// abandonNegotiation drops all recorded ICE session state so the worker treats the
|
||||
// next offer as a fresh start instead of a duplicate of a dead negotiation. The
|
||||
// agent and agentConnecting flags must change together: leaving one stale wedges
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
)
|
||||
|
||||
func TestWorkerICE_RemoteRestartPreservesAdvertisedSession(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
t.Cleanup(w.Close)
|
||||
w.dialFunc = parkDial
|
||||
advertised := w.SessionID()
|
||||
remoteSession := ICESessionID("remote-first")
|
||||
offer := OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"},
|
||||
SessionID: &remoteSession,
|
||||
}
|
||||
w.OnNewOffer(&offer)
|
||||
require.True(t, w.InProgress(), "the first remote session must start ICE")
|
||||
w.muxAgent.Lock()
|
||||
firstAgent := w.agent
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
// The same callback handles answers. A changed remote ID must not create
|
||||
// an unannounced local ID that makes the remote restart on our next offer.
|
||||
secondSession := ICESessionID("remote-restarted")
|
||||
answer := offer
|
||||
answer.SessionID = &secondSession
|
||||
w.OnNewOffer(&answer)
|
||||
assert.Equal(t, advertised, w.SessionID(), "following a remote restart must keep our advertised ID")
|
||||
w.muxAgent.Lock()
|
||||
secondAgent := w.agent
|
||||
w.muxAgent.Unlock()
|
||||
assert.NotSame(t, firstAgent, secondAgent, "the changed remote session must still rebuild ICE")
|
||||
|
||||
w.OnNewOffer(&answer)
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
assert.Same(t, secondAgent, w.agent, "a repeated answer must keep the replacement agent")
|
||||
}
|
||||
|
||||
func TestWorkerICE_LocalCloseChangesAdvertisedSession(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
dialStarted := make(chan struct{})
|
||||
dialDone := make(chan struct{})
|
||||
w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) {
|
||||
close(dialStarted)
|
||||
defer close(dialDone)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
session := ICESessionID("remote-session")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"},
|
||||
SessionID: &session,
|
||||
})
|
||||
<-dialStarted
|
||||
advertised := w.SessionID()
|
||||
w.Close()
|
||||
assert.NotEqual(t, advertised, w.SessionID(), "a local teardown must tell the remote to restart")
|
||||
closedSession := w.SessionID()
|
||||
|
||||
// The abandoned dial goroutine cleans up after Close returned.
|
||||
<-dialDone
|
||||
assert.Never(t, func() bool { return w.SessionID() != closedSession }, 200*time.Millisecond, 10*time.Millisecond,
|
||||
"the late cleanup of a closed negotiation must not restart again")
|
||||
w.Close()
|
||||
assert.Equal(t, closedSession, w.SessionID(), "closing an idle worker must not restart again")
|
||||
}
|
||||
|
||||
// parkDial stands in for the ICE dial. It never connects and returns once the
|
||||
// negotiation is abandoned, so a test decides when a negotiation fails.
|
||||
func parkDial(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func newTestSessionID(t *testing.T) ICESessionID {
|
||||
t.Helper()
|
||||
sid, err := NewICESessionID()
|
||||
require.NoError(t, err)
|
||||
return sid
|
||||
}
|
||||
|
||||
// handshakeSide is one end of a simulated signaling exchange.
|
||||
type handshakeSide interface {
|
||||
// message builds the offer or answer the side would send now.
|
||||
message() OfferAnswer
|
||||
// receive hands a remote offer or answer to the side's ICE logic.
|
||||
receive(msg OfferAnswer)
|
||||
// teardowns counts negotiations the side tore down to follow a remote restart.
|
||||
teardowns() int
|
||||
// failAgent ends the side's current negotiation as an ICE failure does.
|
||||
failAgent()
|
||||
}
|
||||
|
||||
// workerSide drives a real WorkerICE.
|
||||
type workerSide struct {
|
||||
t *testing.T
|
||||
w *WorkerICE
|
||||
replaced int
|
||||
}
|
||||
|
||||
func newWorkerSide(t *testing.T) *workerSide {
|
||||
t.Helper()
|
||||
w := newTestWorkerICE(t)
|
||||
w.dialFunc = parkDial
|
||||
t.Cleanup(w.Close)
|
||||
return &workerSide{t: t, w: w}
|
||||
}
|
||||
|
||||
func (s *workerSide) message() OfferAnswer {
|
||||
sid := s.w.SessionID()
|
||||
ufrag, pwd := s.w.GetLocalUserCredentials()
|
||||
return OfferAnswer{IceCredentials: IceCredentials{UFrag: ufrag, Pwd: pwd}, SessionID: &sid}
|
||||
}
|
||||
|
||||
func (s *workerSide) receive(msg OfferAnswer) {
|
||||
before := s.agent()
|
||||
s.w.OnNewOffer(&msg)
|
||||
if after := s.agent(); before != nil && after != before {
|
||||
s.replaced++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workerSide) teardowns() int { return s.replaced }
|
||||
|
||||
func (s *workerSide) agent() *icemaker.ThreadSafeAgent {
|
||||
s.w.muxAgent.Lock()
|
||||
defer s.w.muxAgent.Unlock()
|
||||
return s.w.agent
|
||||
}
|
||||
|
||||
// failAgent runs the cleanup the dial goroutine or the Failed state callback
|
||||
// performs when the current negotiation dies.
|
||||
func (s *workerSide) failAgent() {
|
||||
s.t.Helper()
|
||||
s.w.muxAgent.Lock()
|
||||
agent, cancel := s.w.agent, s.w.agentDialerCancel
|
||||
s.w.muxAgent.Unlock()
|
||||
require.NotNil(s.t, agent, "failing requires a running negotiation")
|
||||
s.w.closeAgent(agent, cancel)
|
||||
}
|
||||
|
||||
// legacySide models a remote peer running a release from before this change:
|
||||
// when it follows a remote restart it also picks a new session ID of its own,
|
||||
// which it announces only with its next offer or answer.
|
||||
type legacySide struct {
|
||||
t *testing.T
|
||||
sessionID ICESessionID
|
||||
remoteID ICESessionID
|
||||
hasAgent bool
|
||||
replaced int
|
||||
}
|
||||
|
||||
func newLegacySide(t *testing.T) *legacySide {
|
||||
return &legacySide{t: t, sessionID: newTestSessionID(t)}
|
||||
}
|
||||
|
||||
func (s *legacySide) message() OfferAnswer {
|
||||
sid := s.sessionID
|
||||
return OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "legacyufrag", Pwd: "legacy-password-long-enough"},
|
||||
SessionID: &sid,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *legacySide) receive(msg OfferAnswer) {
|
||||
if msg.SessionID == nil {
|
||||
s.hasAgent = true
|
||||
return
|
||||
}
|
||||
if s.hasAgent {
|
||||
if *msg.SessionID == s.remoteID {
|
||||
return
|
||||
}
|
||||
s.replaced++
|
||||
s.sessionID = newTestSessionID(s.t)
|
||||
}
|
||||
s.hasAgent = true
|
||||
s.remoteID = *msg.SessionID
|
||||
}
|
||||
|
||||
func (s *legacySide) teardowns() int { return s.replaced }
|
||||
|
||||
func (s *legacySide) failAgent() {
|
||||
s.hasAgent = false
|
||||
s.remoteID = ""
|
||||
s.sessionID = newTestSessionID(s.t)
|
||||
}
|
||||
|
||||
// exchange runs one guard-driven round in the order Handshaker.Listen uses: the
|
||||
// answerer handles the offer and answers with the session ID it holds
|
||||
// afterwards, and the offerer handles the answer without replying.
|
||||
func exchange(offerer, answerer handshakeSide) {
|
||||
answerer.receive(offerer.message())
|
||||
offerer.receive(answerer.message())
|
||||
}
|
||||
|
||||
// offerPattern decides which side's guard sends the offer in a round.
|
||||
type offerPattern struct {
|
||||
name string
|
||||
picker func(round int, local, remote handshakeSide) (offerer, answerer handshakeSide)
|
||||
}
|
||||
|
||||
var offerPatterns = []offerPattern{
|
||||
{
|
||||
// A routing peer whose relay is down keeps offering on its own.
|
||||
name: "local peer offers",
|
||||
picker: func(_ int, local, remote handshakeSide) (handshakeSide, handshakeSide) {
|
||||
return local, remote
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "both peers offer",
|
||||
picker: func(round int, local, remote handshakeSide) (handshakeSide, handshakeSide) {
|
||||
if round%2 == 0 {
|
||||
return local, remote
|
||||
}
|
||||
return remote, local
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assertSettles runs guard rounds and requires the pair to stop restarting
|
||||
// each other: at most maxTeardowns in total, and none once half the rounds ran.
|
||||
func assertSettles(t *testing.T, pattern offerPattern, local, remote handshakeSide, maxTeardowns int) {
|
||||
t.Helper()
|
||||
const rounds = 10
|
||||
|
||||
total := func() int { return local.teardowns() + remote.teardowns() }
|
||||
start := total()
|
||||
var halfway int
|
||||
for round := range rounds {
|
||||
if round == rounds/2 {
|
||||
halfway = total()
|
||||
}
|
||||
offerer, answerer := pattern.picker(round, local, remote)
|
||||
exchange(offerer, answerer)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, total()-start, maxTeardowns, "the peers must not keep restarting each other")
|
||||
assert.Equal(t, halfway, total(), "the negotiation must be stable in the later rounds")
|
||||
}
|
||||
|
||||
// establish runs the first offer and answer, so both sides negotiate.
|
||||
func establish(t *testing.T, local, remote handshakeSide) {
|
||||
t.Helper()
|
||||
exchange(local, remote)
|
||||
require.Zero(t, local.teardowns()+remote.teardowns(), "the first exchange must not restart anything")
|
||||
}
|
||||
|
||||
func TestICESession_SettlesAfterAgentFailure(t *testing.T) {
|
||||
sides := []struct {
|
||||
name string
|
||||
remote func(t *testing.T) handshakeSide
|
||||
}{
|
||||
{name: "current remote", remote: func(t *testing.T) handshakeSide { return newWorkerSide(t) }},
|
||||
{name: "legacy remote", remote: func(t *testing.T) handshakeSide { return newLegacySide(t) }},
|
||||
}
|
||||
failures := []struct {
|
||||
name string
|
||||
fail func(local, remote handshakeSide)
|
||||
}{
|
||||
{name: "remote agent fails", fail: func(_, remote handshakeSide) { remote.failAgent() }},
|
||||
{name: "local agent fails", fail: func(local, _ handshakeSide) { local.failAgent() }},
|
||||
{name: "both agents fail", fail: func(local, remote handshakeSide) {
|
||||
local.failAgent()
|
||||
remote.failAgent()
|
||||
}},
|
||||
}
|
||||
|
||||
for _, side := range sides {
|
||||
for _, failure := range failures {
|
||||
for _, pattern := range offerPatterns {
|
||||
t.Run(fmt.Sprintf("%s/%s/%s", side.name, failure.name, pattern.name), func(t *testing.T) {
|
||||
local := newWorkerSide(t)
|
||||
remote := side.remote(t)
|
||||
establish(t, local, remote)
|
||||
|
||||
failure.fail(local, remote)
|
||||
assertSettles(t, pattern, local, remote, 2)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestICESession_LocalCloseRestartsRemote covers an explicit teardown, as on a
|
||||
// WireGuard handshake timeout. The remote must start over as well, or it keeps
|
||||
// answering from the negotiation this side just abandoned.
|
||||
func TestICESession_LocalCloseRestartsRemote(t *testing.T) {
|
||||
for _, pattern := range offerPatterns {
|
||||
t.Run(pattern.name, func(t *testing.T) {
|
||||
local := newWorkerSide(t)
|
||||
remote := newWorkerSide(t)
|
||||
establish(t, local, remote)
|
||||
|
||||
local.w.Close()
|
||||
assertSettles(t, pattern, local, remote, 1)
|
||||
assert.Equal(t, 1, remote.teardowns(), "the remote must restart its negotiation exactly once")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestICESession_DuplicateMessagesKeepNegotiation(t *testing.T) {
|
||||
local := newWorkerSide(t)
|
||||
remote := newWorkerSide(t)
|
||||
|
||||
offer := local.message()
|
||||
remote.receive(offer)
|
||||
answer := remote.message()
|
||||
local.receive(answer)
|
||||
|
||||
// Signaling may deliver the same message again, and a peer answers every
|
||||
// offer, including repeats of one it already handled.
|
||||
remote.receive(offer)
|
||||
local.receive(answer)
|
||||
local.receive(remote.message())
|
||||
|
||||
assert.Zero(t, local.teardowns(), "a repeated answer must not restart the negotiation")
|
||||
assert.Zero(t, remote.teardowns(), "a repeated offer must not restart the negotiation")
|
||||
}
|
||||
|
||||
// TestICESession_RemoteWithoutSessionIDKeepsNegotiation covers remote peers
|
||||
// too old to send session IDs: once negotiating, their messages cannot tell a
|
||||
// restart from a repeat, so they must not tear anything down.
|
||||
func TestICESession_RemoteWithoutSessionIDKeepsNegotiation(t *testing.T) {
|
||||
local := newWorkerSide(t)
|
||||
unversioned := OfferAnswer{IceCredentials: IceCredentials{UFrag: "oldufrag", Pwd: "old-password-long-enough"}}
|
||||
|
||||
local.receive(unversioned)
|
||||
require.NotNil(t, local.agent(), "a message without a session ID must still start ICE")
|
||||
advertised := local.w.SessionID()
|
||||
|
||||
for range 3 {
|
||||
local.receive(unversioned)
|
||||
}
|
||||
assert.Zero(t, local.teardowns(), "messages without a session ID must not restart the negotiation")
|
||||
assert.Equal(t, advertised, local.w.SessionID(), "the advertised session must not change")
|
||||
}
|
||||
|
||||
// TestWorkerICE_StaleCleanupKeepsAdvertisedSession covers the cleanup of a
|
||||
// replaced negotiation finishing late, from its dial goroutine or its Closed
|
||||
// state callback. It must neither pick a new session ID, an unannounced local
|
||||
// restart, nor disturb the negotiation that replaced it.
|
||||
func TestWorkerICE_StaleCleanupKeepsAdvertisedSession(t *testing.T) {
|
||||
local := newWorkerSide(t)
|
||||
remote := newWorkerSide(t)
|
||||
establish(t, local, remote)
|
||||
|
||||
local.w.muxAgent.Lock()
|
||||
oldAgent, oldCancel := local.w.agent, local.w.agentDialerCancel
|
||||
local.w.muxAgent.Unlock()
|
||||
|
||||
remote.failAgent()
|
||||
exchange(local, remote)
|
||||
require.Equal(t, 1, local.teardowns(), "the local side must follow the remote restart")
|
||||
advertised := local.w.SessionID()
|
||||
current := local.agent()
|
||||
|
||||
local.w.closeAgent(oldAgent, oldCancel)
|
||||
|
||||
assert.Equal(t, advertised, local.w.SessionID(), "a stale cleanup must not change the advertised session")
|
||||
assert.Same(t, current, local.agent(), "a stale cleanup must keep the current negotiation")
|
||||
assertSettles(t, offerPatterns[1], local, remote, 0)
|
||||
}
|
||||
@@ -101,6 +101,10 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool {
|
||||
return w.relayManager.HasRelayAddress()
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) IsTransportConnected() bool {
|
||||
return w.relayManager.Ready()
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) CloseConn() {
|
||||
w.relayLock.Lock()
|
||||
conn := w.relayedConn
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -198,6 +197,11 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// probing marks a config that exists only to be compared against and then
|
||||
// thrown away, so apply() can skip the work that feeds no verdict.
|
||||
// Unexported, so it never reaches the JSON.
|
||||
probing bool
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values
|
||||
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||
// invocation. Never persisted to disk. Callers query enforcement
|
||||
@@ -300,9 +304,11 @@ func fileExists(path string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// createNewConfig creates a new config generating a new Wireguard key and saving to file
|
||||
func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
config := &Config{
|
||||
// newConfigSkeleton returns the field values a brand-new profile config starts
|
||||
// from, before apply() fills in the rest. Shared with the dry-run baseline so
|
||||
// the two cannot disagree about what "a new config" means.
|
||||
func newConfigSkeleton() *Config {
|
||||
return &Config{
|
||||
// defaults to false only for new (post 0.26) configurations
|
||||
ServerSSHAllowed: util.False(),
|
||||
// Remote jobs are an explicit opt-in and default off, including for
|
||||
@@ -310,6 +316,91 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
RemoteJobsAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUnsetDefaults is the single place where an optional field that carries
|
||||
// no value gets one, and the only place that states what each of those defaults
|
||||
// is. apply() runs it before it compares anything, and that ordering is the
|
||||
// point: with the values named, every comparison below it diffs values instead
|
||||
// of presence.
|
||||
//
|
||||
// Presence-based comparison is what broke `netbird up` for a client configured
|
||||
// through the environment. These fields mean "the effective default" when they
|
||||
// hold nothing — every consumer already reads a nil as the value resolved here,
|
||||
// the SSH toggles in engine_ssh.go and the network monitor in
|
||||
// createEngineConfig — so naming them changes nothing about what runs. But
|
||||
// while they stayed nil, an input restating the default read as a change, and
|
||||
// since the CLI sends every flag whose value came from an environment variable
|
||||
// on each `netbird up`, a client with NB_ENABLE_SSH_ROOT=false restated it
|
||||
// every time and the update-settings gate refused it.
|
||||
//
|
||||
// Filling a field in is not a settings change, so a caller measuring change
|
||||
// must not read the returned bool as one: see WouldChange, which runs a pass
|
||||
// for this and discards its verdict.
|
||||
//
|
||||
// ServerSSHAllowed is the one field whose default depends on the config's age.
|
||||
// A brand-new profile gets false from newConfigSkeleton, which runs before
|
||||
// this, so what is resolved here is only the legacy case: a config written by a
|
||||
// version that had no such field keeps SSH on, for backwards compatibility.
|
||||
func (config *Config) resolveUnsetDefaults() (updated bool) {
|
||||
// Fields that default to false on every platform.
|
||||
for _, field := range []**bool{
|
||||
&config.EnableSSHRoot,
|
||||
&config.EnableSSHSFTP,
|
||||
&config.EnableSSHLocalPortForwarding,
|
||||
&config.EnableSSHRemotePortForwarding,
|
||||
&config.DisableSSHAuth,
|
||||
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
|
||||
// with no value defaults to disabled rather than being turned on.
|
||||
&config.RemoteJobsAllowed,
|
||||
} {
|
||||
if *field == nil {
|
||||
*field = util.False()
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if config.DisableNotifications == nil {
|
||||
log.Infof("setting notifications to disabled by default")
|
||||
config.DisableNotifications = util.True()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.SSHJWTCacheTTL == nil {
|
||||
// A zero TTL disables the JWT cache, which is what no value meant.
|
||||
config.SSHJWTCacheTTL = new(int)
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.NetworkMonitor == nil {
|
||||
// network monitoring is on by default on windows and darwin clients
|
||||
enabled := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
config.NetworkMonitor = &enabled
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.ServerSSHAllowed == nil {
|
||||
if runtime.GOOS == "android" {
|
||||
// default to disabled SSH on Android for security
|
||||
log.Infof("setting SSH server to false by default on Android")
|
||||
config.ServerSSHAllowed = util.False()
|
||||
} else {
|
||||
// enables SSH for configs from old versions to preserve backwards compatibility
|
||||
log.Infof("falling back to enabled SSH server for pre-existing configuration")
|
||||
config.ServerSSHAllowed = util.True()
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// createNewConfig resolves a new config in memory, with no identity: whoever
|
||||
// needs the peer's keys calls EnsureIdentity and persists the result, so a read
|
||||
// that lands on a missing file cannot hand back a config carrying keys that
|
||||
// nothing will ever write down.
|
||||
func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
config := newConfigSkeleton()
|
||||
|
||||
if _, err := config.apply(input); err != nil {
|
||||
return nil, err
|
||||
@@ -318,6 +409,52 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// createProvisionedConfig is createNewConfig plus the peer's identity, for the
|
||||
// callers that go on to persist the config or to connect with it.
|
||||
func createProvisionedConfig(input ConfigInput) (*Config, error) {
|
||||
config, err := createNewConfig(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := config.EnsureIdentity(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// EnsureIdentity generates the keys that identify this peer if the config does
|
||||
// not carry them yet, reporting whether it had to generate any.
|
||||
//
|
||||
// It is deliberately not part of apply(). Everything apply() fills in is a
|
||||
// default it can recompute on the next read, but a generated key is not: it
|
||||
// has to be persisted, or the peer comes back with a different WireGuard
|
||||
// identity and re-registers. Having apply() generate keys is what forced every
|
||||
// read of a config to write it back — so identity provisioning is its own step
|
||||
// now, and the callers that perform it write the result out explicitly.
|
||||
func (config *Config) EnsureIdentity() (bool, error) {
|
||||
generated := false
|
||||
|
||||
if config.PrivateKey == "" {
|
||||
log.Infof("generated new Wireguard key")
|
||||
config.PrivateKey = generateKey()
|
||||
generated = true
|
||||
}
|
||||
|
||||
if config.SSHKey == "" {
|
||||
log.Infof("generated new SSH key")
|
||||
pem, err := ssh.GeneratePrivateKey(ssh.ED25519)
|
||||
if err != nil {
|
||||
return generated, err
|
||||
}
|
||||
config.SSHKey = string(pem)
|
||||
generated = true
|
||||
}
|
||||
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
if config.Name != "" {
|
||||
sanitized, err := sanitizeDisplayName(config.Name)
|
||||
@@ -329,6 +466,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Every optional field gets its value here, before anything below compares
|
||||
// one. See resolveUnsetDefaults for why that ordering is the point.
|
||||
if config.resolveUnsetDefaults() {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.ManagementURL == nil {
|
||||
log.Infof("using default Management URL %s", DefaultManagementURL)
|
||||
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
||||
@@ -336,20 +480,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if input.ManagementURL != "" && input.ManagementURL != config.ManagementURL.String() {
|
||||
log.Infof("new Management URL provided, updated to %#v (old value %#v)",
|
||||
input.ManagementURL, config.ManagementURL.String())
|
||||
// The comparison is on the endpoint the URL addresses, not on its
|
||||
// spelling: the same endpoint can be written several ways (an implicit
|
||||
// :443, a trailing slash, a different host case), and treating an
|
||||
// equivalent URL as new would rewrite the config and report a settings
|
||||
// change where the configuration does not actually change.
|
||||
if input.ManagementURL != "" {
|
||||
URL, err := parseURL("Management URL", input.ManagementURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
config.ManagementURL = URL
|
||||
updated = true
|
||||
} else if config.ManagementURL == nil {
|
||||
log.Infof("using default Management URL %s", DefaultManagementURL)
|
||||
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
if !SameServiceURL(URL, config.ManagementURL) {
|
||||
log.Infof("new Management URL provided, updated to %#v (old value %#v)",
|
||||
URL.String(), config.ManagementURL.String())
|
||||
config.ManagementURL = URL
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,31 +505,20 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if input.AdminURL != "" && input.AdminURL != config.AdminURL.String() {
|
||||
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
|
||||
input.AdminURL, config.AdminURL.String())
|
||||
// The admin panel is opened, not dialed, so unlike the Management URL its
|
||||
// path is part of what identifies it: a panel served under /netbird is not
|
||||
// the one served at the root.
|
||||
if input.AdminURL != "" {
|
||||
newURL, err := parseURL("Admin Panel URL", input.AdminURL)
|
||||
if err != nil {
|
||||
return updated, err
|
||||
}
|
||||
config.AdminURL = newURL
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.PrivateKey == "" {
|
||||
log.Infof("generated new Wireguard key")
|
||||
config.PrivateKey = generateKey()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.SSHKey == "" {
|
||||
log.Infof("generated new SSH key")
|
||||
pem, err := ssh.GeneratePrivateKey(ssh.ED25519)
|
||||
if err != nil {
|
||||
return false, err
|
||||
if !SameServiceURLIncludingPath(newURL, config.AdminURL) {
|
||||
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
|
||||
newURL.String(), config.AdminURL.String())
|
||||
config.AdminURL = newURL
|
||||
updated = true
|
||||
}
|
||||
config.SSHKey = string(pem)
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
|
||||
@@ -405,7 +539,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.NATExternalIPs != nil && !reflect.DeepEqual(config.NATExternalIPs, input.NATExternalIPs) {
|
||||
// slices.Equal, not reflect.DeepEqual, and for the same reason the DNS
|
||||
// labels below use it: DeepEqual calls a nil slice and an empty one
|
||||
// different, while both mean "no NAT mappings". A profile stores the
|
||||
// absent list as JSON null and reads it back nil, and `netbird up` sends
|
||||
// CleanNATExternalIPs — an empty list — whenever NB_EXTERNAL_IP_MAP is set
|
||||
// to nothing, so the two met on every start and the gate read a no-op as a
|
||||
// settings change.
|
||||
if input.NATExternalIPs != nil && !slices.Equal(config.NATExternalIPs, input.NATExternalIPs) {
|
||||
log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])",
|
||||
strings.Join(input.NATExternalIPs, " "),
|
||||
strings.Join(config.NATExternalIPs, " "))
|
||||
@@ -443,21 +584,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
|
||||
if input.NetworkMonitor != nil && *input.NetworkMonitor != *config.NetworkMonitor {
|
||||
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
||||
config.NetworkMonitor = input.NetworkMonitor
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.NetworkMonitor == nil {
|
||||
// enable network monitoring by default on windows and darwin clients
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
enabled := true
|
||||
config.NetworkMonitor = &enabled
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress {
|
||||
log.Infof("updating custom DNS address %#v (old value %#v)",
|
||||
string(input.CustomDNSAddress), config.CustomDNSAddress)
|
||||
@@ -490,7 +622,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) {
|
||||
if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
|
||||
if *input.ServerSSHAllowed {
|
||||
log.Infof("enabling SSH server")
|
||||
} else {
|
||||
@@ -498,20 +630,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
}
|
||||
config.ServerSSHAllowed = input.ServerSSHAllowed
|
||||
updated = true
|
||||
} else if config.ServerSSHAllowed == nil {
|
||||
if runtime.GOOS == "android" {
|
||||
// default to disabled SSH on Android for security
|
||||
log.Infof("setting SSH server to false by default on Android")
|
||||
config.ServerSSHAllowed = util.False()
|
||||
} else {
|
||||
// enables SSH for configs from old versions to preserve backwards compatibility
|
||||
log.Infof("falling back to enabled SSH server for pre-existing configuration")
|
||||
config.ServerSSHAllowed = util.True()
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) {
|
||||
if input.RemoteJobsAllowed != nil && *input.RemoteJobsAllowed != *config.RemoteJobsAllowed {
|
||||
if *input.RemoteJobsAllowed {
|
||||
log.Infof("enabling remote jobs")
|
||||
} else {
|
||||
@@ -519,14 +640,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
}
|
||||
config.RemoteJobsAllowed = input.RemoteJobsAllowed
|
||||
updated = true
|
||||
} else if config.RemoteJobsAllowed == nil {
|
||||
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
|
||||
// with no value defaults to disabled rather than being turned on.
|
||||
config.RemoteJobsAllowed = util.False()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
|
||||
if input.EnableSSHRoot != nil && *input.EnableSSHRoot != *config.EnableSSHRoot {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
} else {
|
||||
@@ -536,7 +652,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) {
|
||||
if input.EnableSSHSFTP != nil && *input.EnableSSHSFTP != *config.EnableSSHSFTP {
|
||||
if *input.EnableSSHSFTP {
|
||||
log.Infof("enabling SSH SFTP subsystem")
|
||||
} else {
|
||||
@@ -546,7 +662,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) {
|
||||
if input.EnableSSHLocalPortForwarding != nil && *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding {
|
||||
if *input.EnableSSHLocalPortForwarding {
|
||||
log.Infof("enabling SSH local port forwarding")
|
||||
} else {
|
||||
@@ -556,7 +672,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) {
|
||||
if input.EnableSSHRemotePortForwarding != nil && *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding {
|
||||
if *input.EnableSSHRemotePortForwarding {
|
||||
log.Infof("enabling SSH remote port forwarding")
|
||||
} else {
|
||||
@@ -566,7 +682,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) {
|
||||
if input.DisableSSHAuth != nil && *input.DisableSSHAuth != *config.DisableSSHAuth {
|
||||
if *input.DisableSSHAuth {
|
||||
log.Infof("disabling SSH authentication")
|
||||
} else {
|
||||
@@ -576,7 +692,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) {
|
||||
if input.SSHJWTCacheTTL != nil && *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL {
|
||||
log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL)
|
||||
config.SSHJWTCacheTTL = input.SSHJWTCacheTTL
|
||||
updated = true
|
||||
@@ -659,13 +775,16 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
|
||||
// Assigning the pointer, not writing through it: a config that carries no
|
||||
// version yet would otherwise be a nil dereference, and a panic inside a
|
||||
// request handler is not a way to fail.
|
||||
if input.SyncMessageVersion != nil && (config.SyncMessageVersion == nil || *input.SyncMessageVersion != *config.SyncMessageVersion) {
|
||||
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
|
||||
*config.SyncMessageVersion = *input.SyncMessageVersion
|
||||
config.SyncMessageVersion = input.SyncMessageVersion
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
|
||||
if input.DisableNotifications != nil && *input.DisableNotifications != *config.DisableNotifications {
|
||||
if *input.DisableNotifications {
|
||||
log.Infof("disabling notifications")
|
||||
} else {
|
||||
@@ -675,24 +794,24 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.DisableNotifications == nil {
|
||||
disabled := true
|
||||
config.DisableNotifications = &disabled
|
||||
log.Infof("setting notifications to disabled by default")
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ClientCertKeyPath != "" {
|
||||
// Compared, not just assigned: restating the path a config already holds
|
||||
// changes nothing, and reporting it as an update makes a caller that
|
||||
// re-sends its own configuration look like one asking to change it.
|
||||
if input.ClientCertKeyPath != "" && input.ClientCertKeyPath != config.ClientCertKeyPath {
|
||||
config.ClientCertKeyPath = input.ClientCertKeyPath
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ClientCertPath != "" {
|
||||
if input.ClientCertPath != "" && input.ClientCertPath != config.ClientCertPath {
|
||||
config.ClientCertPath = input.ClientCertPath
|
||||
updated = true
|
||||
}
|
||||
|
||||
if config.ClientCertPath != "" && config.ClientCertKeyPath != "" {
|
||||
// Not on a probe: the loaded pair feeds the connection, never the
|
||||
// comparison, and this would otherwise run on every gated SetConfig and
|
||||
// Login — twice per request — including those that are refused or change
|
||||
// nothing, logging an error per request when the files are missing.
|
||||
if !config.probing && config.ClientCertPath != "" && config.ClientCertKeyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to load mTLS cert/key pair: ", err)
|
||||
@@ -886,6 +1005,49 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
return parseURL(serviceName, serviceURL)
|
||||
}
|
||||
|
||||
// SameServiceURL reports whether two service URLs address the same endpoint:
|
||||
// same scheme, same host compared case-insensitively as DNS names are, and
|
||||
// same effective port, where an absent port means the scheme's default.
|
||||
//
|
||||
// This is the one comparison every caller deciding "did this URL change?" must
|
||||
// use. A string comparison answers a different question: "https://host",
|
||||
// "https://host/" and "https://HOST:443" are one endpoint written three ways,
|
||||
// and reading them as three values makes a client that restates its own
|
||||
// management URL look like a client asking to be repointed. A nil operand
|
||||
// matches only another nil one.
|
||||
//
|
||||
// The path plays no part: a management URL is dialed, and only its host and
|
||||
// port are. util.SameServiceURL is this comparison plus the path, which is
|
||||
// what SameServiceURLIncludingPath needs and delegates to.
|
||||
func SameServiceURL(a, b *url.URL) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
|
||||
return strings.EqualFold(a.Scheme, b.Scheme) &&
|
||||
strings.EqualFold(a.Hostname(), b.Hostname()) &&
|
||||
util.ServiceURLPort(a) == util.ServiceURLPort(b)
|
||||
}
|
||||
|
||||
// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries
|
||||
// past its endpoint: path, query, fragment and userinfo.
|
||||
//
|
||||
// Use it for a URL that gets opened rather than dialed. The admin panel can
|
||||
// live under a path, so two URLs with the same endpoint and different paths are
|
||||
// two different panels — where for a URL the client dials over gRPC only the
|
||||
// endpoint is ever used. Equivalent spellings still compare equal: a missing
|
||||
// path and "/" are the same root, and so is a trailing slash on any path.
|
||||
func SameServiceURLIncludingPath(a, b *url.URL) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
|
||||
return util.SameServiceURL(a, b) &&
|
||||
a.RawQuery == b.RawQuery &&
|
||||
a.Fragment == b.Fragment &&
|
||||
a.User.String() == b.User.String()
|
||||
}
|
||||
|
||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
@@ -930,6 +1092,84 @@ func isPreSharedKeyHidden(preSharedKey *string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// WouldChange reports whether applying input would modify any field the
|
||||
// config persists, leaving the receiver untouched. It is the dry-run half of
|
||||
// UpdateConfig and reuses the very same diff logic (Config.apply), so a
|
||||
// caller asking "is this a settings change?" cannot drift from what an
|
||||
// actual update would do, nor go stale when a new field is added.
|
||||
//
|
||||
// A redacted pre-shared key is collapsed to "unset" exactly as
|
||||
// UpdateOrCreateConfig does, so a UI that round-trips the mask is not read as
|
||||
// a request for a new key.
|
||||
//
|
||||
// A nil receiver means the profile holds no config yet, so the baseline is the
|
||||
// config the daemon would create for it: input values matching those defaults
|
||||
// change nothing, anything else does.
|
||||
func (config *Config) WouldChange(input ConfigInput) (bool, error) {
|
||||
probe := config.clone()
|
||||
if probe == nil {
|
||||
baseline, err := newDryRunBaseline(input.ConfigPath)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("build default config baseline: %w", err)
|
||||
}
|
||||
probe = baseline
|
||||
}
|
||||
probe.probing = true
|
||||
|
||||
// Normalize before measuring. apply() reports two different things through
|
||||
// one bool: an input that changed a value, and a field it had to fill in
|
||||
// because the config carried none. Only the first is a settings change, so
|
||||
// the filling-in gets a pass of its own whose verdict is discarded, and the
|
||||
// pass that answers the caller runs against a config with nothing left to
|
||||
// fill in.
|
||||
//
|
||||
// Readers already hand out normalized configs — readConfig applies an empty
|
||||
// input for this very reason — so this is normally a no-op. But a gate that
|
||||
// refuses a request must not depend on where its caller got the config
|
||||
// from, and it must not start reading "this profile predates a field" as
|
||||
// "the caller asked for a change" the day someone adds one.
|
||||
if _, err := probe.apply(ConfigInput{ConfigPath: input.ConfigPath}); err != nil {
|
||||
return true, fmt.Errorf("normalize the config to diff against: %w", err)
|
||||
}
|
||||
|
||||
if isPreSharedKeyHidden(input.PreSharedKey) {
|
||||
input.PreSharedKey = nil
|
||||
}
|
||||
|
||||
return probe.apply(input)
|
||||
}
|
||||
|
||||
// newDryRunBaseline builds the config a brand-new profile would start from, for
|
||||
// a dry run to compare an input against. It is createNewConfig without the
|
||||
// identity: this config exists only to be compared against and thrown away, and
|
||||
// no ConfigInput field maps to either key.
|
||||
func newDryRunBaseline(configPath string) (*Config, error) {
|
||||
baseline := newConfigSkeleton()
|
||||
|
||||
if _, err := baseline.apply(ConfigInput{ConfigPath: configPath}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return baseline, nil
|
||||
}
|
||||
|
||||
// clone returns a copy of the config that apply can be run against without the
|
||||
// original observing the writes, or nil for a nil receiver. Only what apply
|
||||
// mutates in place needs detaching, which is the slices it replaces or appends
|
||||
// to: every pointer field it touches is reassigned rather than written through,
|
||||
// and ClientCertKeyPair is only overwritten.
|
||||
func (config *Config) clone() *Config {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
probe := *config
|
||||
probe.IFaceBlackList = slices.Clone(config.IFaceBlackList)
|
||||
probe.NATExternalIPs = slices.Clone(config.NATExternalIPs)
|
||||
probe.DNSLabels = slices.Clone(config.DNSLabels)
|
||||
return &probe
|
||||
}
|
||||
|
||||
// UpdateConfig update existing configuration according to input configuration and return with the configuration
|
||||
func UpdateConfig(input ConfigInput) (*Config, error) {
|
||||
configExists, err := fileExists(input.ConfigPath)
|
||||
@@ -940,6 +1180,14 @@ func UpdateConfig(input ConfigInput) (*Config, error) {
|
||||
return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath)
|
||||
}
|
||||
|
||||
// A UI that round-trips the mask GetConfig hands it back is asking to keep
|
||||
// the stored key, not to set the mask as the new one. UpdateOrCreateConfig
|
||||
// and DirectUpdateOrCreateConfig already collapse it; this one did not, so
|
||||
// the same round-trip through SetConfig replaced the key with asterisks.
|
||||
if isPreSharedKeyHidden(input.PreSharedKey) {
|
||||
input.PreSharedKey = nil
|
||||
}
|
||||
|
||||
return update(input)
|
||||
}
|
||||
|
||||
@@ -951,7 +1199,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
|
||||
}
|
||||
if !configExists {
|
||||
log.Infof("generating new config %s", input.ConfigPath)
|
||||
cfg, err := createNewConfig(input)
|
||||
cfg, err := createProvisionedConfig(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -976,12 +1224,20 @@ func update(input ConfigInput) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A write path is a provisioning point: a stored profile can legitimately
|
||||
// carry no identity (a mobile logout clears the keys in place), and the
|
||||
// next config write is what has to mint a new one. Reads leave that alone.
|
||||
identityGenerated, err := config.EnsureIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := config.apply(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if updated {
|
||||
if updated || identityGenerated {
|
||||
if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -990,8 +1246,8 @@ func update(input ConfigInput) (*Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist
|
||||
func GetConfig(configPath string) (*Config, error) {
|
||||
// GetExistingConfig reads and returns the config if it exists on disk. Fails otherwise.
|
||||
func GetExistingConfig(configPath string) (*Config, error) {
|
||||
return readConfig(configPath, false)
|
||||
}
|
||||
|
||||
@@ -1074,17 +1330,27 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri
|
||||
return newConfig, nil
|
||||
}
|
||||
|
||||
// CreateInMemoryConfig generate a new config but do not write out it to the store
|
||||
// CreateInMemoryConfig generate a new config but do not write out it to the store.
|
||||
// It carries an identity: callers connect with what they get back.
|
||||
func CreateInMemoryConfig(input ConfigInput) (*Config, error) {
|
||||
return createNewConfig(input)
|
||||
return createProvisionedConfig(input)
|
||||
}
|
||||
|
||||
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
|
||||
func ReadConfig(configPath string) (*Config, error) {
|
||||
// ReadConfigOrDefault reads the profile config at configPath, or resolves the
|
||||
// default config in memory when the file does not exist. It never writes, and
|
||||
// never mints an identity — EnsureIdentity is where that happens, so the
|
||||
// caller that provisions is also the one that persists.
|
||||
func ReadConfigOrDefault(configPath string) (*Config, error) {
|
||||
return readConfig(configPath, true)
|
||||
}
|
||||
|
||||
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
|
||||
// readConfig reads the profile config at configPath. createIfMissing resolves a
|
||||
// default config in memory when the file is absent, rather than erroring.
|
||||
//
|
||||
// Reads are pure. This used to write the config back whenever apply() had to
|
||||
// fill in a default the file was missing, which quietly made every reader a
|
||||
// writer: a gate deciding whether to refuse a request, a UI listing profiles,
|
||||
// a mobile getter reading a single preference.
|
||||
func readConfig(configPath string, createIfMissing bool) (*Config, error) {
|
||||
configExists, err := fileExists(configPath)
|
||||
if err != nil {
|
||||
@@ -1102,12 +1368,8 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
// initialize through apply() without changes
|
||||
if changed, err := config.apply(ConfigInput{}); err != nil {
|
||||
if _, err := config.apply(ConfigInput{}); err != nil {
|
||||
return nil, err
|
||||
} else if changed {
|
||||
if err = WriteOutConfig(configPath, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
@@ -1115,13 +1377,7 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) {
|
||||
return nil, fmt.Errorf("config file %s does not exist", configPath)
|
||||
}
|
||||
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = WriteOutConfig(configPath, cfg)
|
||||
return cfg, err
|
||||
return createNewConfig(ConfigInput{ConfigPath: configPath})
|
||||
}
|
||||
|
||||
// WriteOutConfig write put the prepared config to the given path
|
||||
@@ -1144,7 +1400,7 @@ func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) {
|
||||
}
|
||||
if !configExists {
|
||||
log.Infof("generating new config %s", input.ConfigPath)
|
||||
cfg, err := createNewConfig(input)
|
||||
cfg, err := createProvisionedConfig(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1171,12 +1427,18 @@ func directUpdate(input ConfigInput) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Same provisioning point as update(); see the note there.
|
||||
identityGenerated, err := config.EnsureIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := config.apply(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if updated {
|
||||
if updated || identityGenerated {
|
||||
if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1198,7 +1460,16 @@ func ConfigToJSON(config *Config) (string, error) {
|
||||
|
||||
// ConfigFromJSON deserializes a JSON string to a Config struct.
|
||||
// This is useful for restoring config from alternative storage mechanisms.
|
||||
// After unmarshaling, defaults are applied to ensure the config is fully initialized.
|
||||
// After unmarshaling, defaults are applied to ensure the config is fully
|
||||
// initialized.
|
||||
//
|
||||
// The peer identity is deliberately none of its business, in either direction.
|
||||
// It does not generate one: a read cannot hand back keys that nothing will
|
||||
// write down (see ReadConfigOrDefault). Nor does it refuse a document that
|
||||
// carries none, because a config legitimately has no identity between a logout
|
||||
// and the next login — mobile logout clears both keys in place — and this is
|
||||
// also the deserializer the iOS SDK copies a config through. Whoever goes on
|
||||
// to connect is where an absent identity has to be answered.
|
||||
func ConfigFromJSON(jsonStr string) (*Config, error) {
|
||||
config := &Config{}
|
||||
err := json.Unmarshal([]byte(jsonStr), config)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The serialized form is how the tvOS SDK stores a profile and how the iOS SDK
|
||||
// copies one in memory, so it must round-trip whatever a profile legitimately
|
||||
// holds — including no identity at all, which is the state mobile logout leaves
|
||||
// behind when it clears both keys in place. Refusing that document here broke
|
||||
// logout, profile switching and the login that follows them.
|
||||
func TestConfigFromJSONRoundTripsALoggedOutProfile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "exported.json")
|
||||
stored, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, stored.PrivateKey, "a provisioned config is the fixture this test starts from")
|
||||
require.NotEmpty(t, stored.SSHKey)
|
||||
|
||||
exported, err := ConfigToJSON(stored)
|
||||
require.NoError(t, err)
|
||||
|
||||
restored, err := ConfigFromJSON(exported)
|
||||
require.NoError(t, err, "a config exported after a login must load")
|
||||
require.Equal(t, stored.PrivateKey, restored.PrivateKey, "the restored peer is not the stored one")
|
||||
require.Equal(t, stored.SSHKey, restored.SSHKey)
|
||||
|
||||
// What mobile logout leaves on disk.
|
||||
loggedOut := stored.clone()
|
||||
loggedOut.PrivateKey = ""
|
||||
loggedOut.SSHKey = ""
|
||||
|
||||
document, err := ConfigToJSON(loggedOut)
|
||||
require.NoError(t, err)
|
||||
|
||||
reloaded, err := ConfigFromJSON(document)
|
||||
require.NoError(t, err, "a logged-out profile must still load")
|
||||
require.Empty(t, reloaded.PrivateKey, "loading must not mint a key nothing will write down")
|
||||
require.Empty(t, reloaded.SSHKey)
|
||||
require.Equal(t, stored.ManagementURL.String(), reloaded.ManagementURL.String(),
|
||||
"the rest of the profile survives the logout")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// optionalBoolFields lists the *bool fields of Config by name, derived from the
|
||||
// type so a field added later is covered without touching these tests.
|
||||
func optionalBoolFields() []string {
|
||||
pointerToBool := reflect.TypeOf((*bool)(nil))
|
||||
|
||||
var fields []string
|
||||
configType := reflect.TypeOf(Config{})
|
||||
for i := range configType.NumField() {
|
||||
field := configType.Field(i)
|
||||
if field.Type == pointerToBool && field.Tag.Get("json") != "-" {
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func requireNoUnsetOptionalBool(t *testing.T, config *Config, context string) {
|
||||
t.Helper()
|
||||
|
||||
value := reflect.ValueOf(*config)
|
||||
for _, name := range optionalBoolFields() {
|
||||
require.False(t, value.FieldByName(name).IsNil(),
|
||||
"%s left %s unset, so its readers have to invent a default and a diff of it compares presence instead of value", context, name)
|
||||
}
|
||||
}
|
||||
|
||||
// An optional bool must not be tristate. While one can be nil, true or false,
|
||||
// every reader has to invent the meaning of nil, and — the reason this test
|
||||
// exists — a diff of the config ends up comparing presence rather than value:
|
||||
// that is what made the update-settings gate refuse `netbird up` for a client
|
||||
// restating its own defaults. apply() is where a config becomes complete, so
|
||||
// the invariant belongs to it: no *bool may come out of apply() unset.
|
||||
func TestApplyLeavesNoOptionalBoolUnset(t *testing.T) {
|
||||
require.NotEmpty(t, optionalBoolFields(), "the invariant is only meaningful while Config has optional bools")
|
||||
|
||||
t.Run("a config built from scratch", func(t *testing.T) {
|
||||
config := newConfigSkeleton()
|
||||
_, err := config.apply(ConfigInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoUnsetOptionalBool(t, config, "apply on a new config")
|
||||
})
|
||||
|
||||
t.Run("a config file that predates every optional field", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"WgIface":"wt0"}`), 0o600))
|
||||
|
||||
config, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoUnsetOptionalBool(t, config, "a read of a legacy config")
|
||||
})
|
||||
|
||||
t.Run("a config file that stores them as null", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "null.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
||||
require.NoError(t, err)
|
||||
unsetOnDisk(t, path, optionalBoolFields()...)
|
||||
|
||||
config, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoUnsetOptionalBool(t, config, "a read of a config storing nulls")
|
||||
})
|
||||
}
|
||||
|
||||
// The same invariant on disk: what a write leaves in the file is what the next
|
||||
// client to read it starts from, so no write may store a null.
|
||||
func TestNoWriteStoresAnUnsetOptionalBool(t *testing.T) {
|
||||
requireNoNullOnDisk := func(t *testing.T, path string, context string) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var stored map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &stored))
|
||||
|
||||
for _, name := range optionalBoolFields() {
|
||||
value, present := stored[name]
|
||||
require.True(t, present, "%s did not store %s at all", context, name)
|
||||
require.NotEqual(t, "null", string(value), "%s stored %s as null", context, name)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("UpdateOrCreateConfig", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "created.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL})
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoNullOnDisk(t, path, "UpdateOrCreateConfig")
|
||||
})
|
||||
|
||||
t.Run("UpdateConfig over a config storing nulls", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stored.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
||||
require.NoError(t, err)
|
||||
unsetOnDisk(t, path, optionalBoolFields()...)
|
||||
|
||||
_, err = UpdateConfig(ConfigInput{ConfigPath: path, ManagementURL: "https://mgmt.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoNullOnDisk(t, path, "UpdateConfig")
|
||||
})
|
||||
|
||||
// Renaming used to copy the file back through a bare Unmarshal, which
|
||||
// preserved the nulls a pre-fix client had written.
|
||||
t.Run("RenameProfile", func(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
unsetOnDisk(t, created.Path, optionalBoolFields()...)
|
||||
|
||||
require.NoError(t, sm.RenameProfile(created.ID, username, "office"))
|
||||
|
||||
requireNoNullOnDisk(t, created.Path, "RenameProfile")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeCertPair writes a throwaway certificate and key, so apply() has
|
||||
// something real to load rather than a missing file it would only log about.
|
||||
func writeCertPair(t *testing.T) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "probe-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath = filepath.Join(dir, "client.crt")
|
||||
keyPath = filepath.Join(dir, "client.key")
|
||||
require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// The dry run behind the update-settings gate must not read the mTLS pair off
|
||||
// disk. The loaded pair feeds the connection, never the comparison, and the
|
||||
// gate runs it on every SetConfig and Login — twice per request — including the
|
||||
// ones it refuses.
|
||||
func TestProbeDoesNotLoadTheCertificatePair(t *testing.T) {
|
||||
certPath, keyPath := writeCertPair(t)
|
||||
|
||||
t.Run("a real apply loads it", func(t *testing.T) {
|
||||
config := newConfigSkeleton()
|
||||
config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath
|
||||
|
||||
_, err := config.apply(ConfigInput{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config.ClientCertKeyPair, "the connection would have no client certificate")
|
||||
})
|
||||
|
||||
t.Run("a probe does not", func(t *testing.T) {
|
||||
config := newConfigSkeleton()
|
||||
config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath
|
||||
config.probing = true
|
||||
|
||||
_, err := config.apply(ConfigInput{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, config.ClientCertKeyPair, "the dry run read the certificate off disk")
|
||||
})
|
||||
|
||||
// And the verdict is the same either way, which is the only thing the gate
|
||||
// asks of the probe.
|
||||
t.Run("the verdict is unaffected", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mtls.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: DefaultManagementURL,
|
||||
ClientCertPath: certPath,
|
||||
ClientCertKeyPath: keyPath,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
changed, err := stored.WouldChange(ConfigInput{ClientCertPath: certPath, ClientCertKeyPath: keyPath})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "restating the stored certificate paths is not a change")
|
||||
|
||||
changed, err = stored.WouldChange(ConfigInput{ClientCertPath: filepath.Join(t.TempDir(), "other.crt")})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "a different certificate path is a change")
|
||||
})
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestWireguardPortZeroExplicit(t *testing.T) {
|
||||
assert.Equal(t, 0, config.WgPort, "WgPort should be 0 when explicitly set by user")
|
||||
|
||||
// Verify it persists
|
||||
readConfig, err := GetConfig(configPath)
|
||||
readConfig, err := GetExistingConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, readConfig.WgPort, "WgPort should remain 0 after reading from file")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func seededConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "seeded.json")
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
PreSharedKey: strPointer("stored-key"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func strPointer(s string) *string { return &s }
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestWouldChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input ConfigInput
|
||||
want bool
|
||||
}{
|
||||
{name: "empty input", input: ConfigInput{}, want: false},
|
||||
{name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false},
|
||||
{name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false},
|
||||
{name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true},
|
||||
{name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false},
|
||||
{name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false},
|
||||
{name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true},
|
||||
{name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true},
|
||||
{name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := seededConfig(t)
|
||||
|
||||
changed, err := cfg.WouldChange(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, changed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The dry run must not be observable on the config it is run against: it
|
||||
// decides whether a write is allowed, it does not perform one.
|
||||
func TestWouldChangeLeavesTheConfigAlone(t *testing.T) {
|
||||
cfg := seededConfig(t)
|
||||
blacklist := len(cfg.IFaceBlackList)
|
||||
|
||||
changed, err := cfg.WouldChange(ConfigInput{
|
||||
ManagementURL: "https://other.example:443",
|
||||
PreSharedKey: strPointer("other-key"),
|
||||
ExtraIFaceBlackList: []string{"nb-probe0"},
|
||||
DNSLabels: domain.FromPunycodeList([]string{"probe"}),
|
||||
NATExternalIPs: []string{"1.2.3.4"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String())
|
||||
require.Equal(t, "stored-key", cfg.PreSharedKey)
|
||||
require.Len(t, cfg.IFaceBlackList, blacklist)
|
||||
require.Empty(t, cfg.DNSLabels)
|
||||
require.Empty(t, cfg.NATExternalIPs)
|
||||
}
|
||||
|
||||
// A nil config means the profile holds nothing yet, so the baseline is what
|
||||
// the daemon would create for it.
|
||||
func TestWouldChangeWithoutAStoredConfig(t *testing.T) {
|
||||
var cfg *Config
|
||||
|
||||
changed, err := cfg.WouldChange(ConfigInput{})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "a request carrying nothing cannot change anything")
|
||||
|
||||
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "the default management URL is what would be written anyway")
|
||||
|
||||
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
}
|
||||
|
||||
func TestWouldChangeReportsAnInvalidInput(t *testing.T) {
|
||||
cfg := seededConfig(t)
|
||||
|
||||
_, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Reads must not write. A config file missing a field apply() fills in (MTU,
|
||||
// here) is what used to trigger the write-back.
|
||||
func TestReadsDoNotWriteTheConfigBack(t *testing.T) {
|
||||
denormalized := []byte(`{"WgIface":"wt0"}`)
|
||||
|
||||
for name, read := range map[string]func(string) (*Config, error){
|
||||
"GetExistingConfig": GetExistingConfig,
|
||||
"ReadConfigOrDefault": ReadConfigOrDefault,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "profile.json")
|
||||
require.NoError(t, os.WriteFile(path, denormalized, 0o600))
|
||||
|
||||
cfg, err := read(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory")
|
||||
require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either")
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ReadConfigOrDefault resolves a default config for a profile that has no file
|
||||
// yet, and that must not create the file either.
|
||||
func TestReadConfigDoesNotCreateTheFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "absent.json")
|
||||
|
||||
cfg, err := ReadConfigOrDefault(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
|
||||
_, err = os.Stat(path)
|
||||
require.True(t, os.IsNotExist(err), "ReadConfigOrDefault created the config file")
|
||||
}
|
||||
|
||||
// The identity is the one thing a read cannot recompute, so it is provisioned
|
||||
// on request and its caller persists it.
|
||||
func TestEnsureIdentity(t *testing.T) {
|
||||
cfg := newConfigSkeleton()
|
||||
|
||||
generated, err := cfg.EnsureIdentity()
|
||||
require.NoError(t, err)
|
||||
require.True(t, generated)
|
||||
require.NotEmpty(t, cfg.PrivateKey)
|
||||
require.NotEmpty(t, cfg.SSHKey)
|
||||
|
||||
key := cfg.PrivateKey
|
||||
generated, err = cfg.EnsureIdentity()
|
||||
require.NoError(t, err)
|
||||
require.False(t, generated, "a config that already has an identity keeps it")
|
||||
require.Equal(t, key, cfg.PrivateKey)
|
||||
}
|
||||
|
||||
// One endpoint written several ways is one endpoint. A gate that compared
|
||||
// spellings refused a client restating its own management URL with a trailing
|
||||
// slash, which is a normal way to write it.
|
||||
func TestSameServiceURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true},
|
||||
{a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true},
|
||||
{a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true},
|
||||
{a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true},
|
||||
{a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true},
|
||||
{a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false},
|
||||
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false},
|
||||
{a: "https://mgmt.example.com", b: "https://other.example.com", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a+" vs "+tt.b, func(t *testing.T) {
|
||||
a, err := ParseServiceURL("a", tt.a)
|
||||
require.NoError(t, err)
|
||||
b, err := ParseServiceURL("b", tt.b)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tt.want, SameServiceURL(a, b))
|
||||
require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same spellings, through the dry run the update-settings gate uses.
|
||||
func TestWouldChangeIgnoresURLSpelling(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "seeded.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, spelling := range []string{
|
||||
"https://mgmt.example.com",
|
||||
"https://mgmt.example.com/",
|
||||
"https://mgmt.example.com:443",
|
||||
"https://mgmt.example.com:443/",
|
||||
"https://MGMT.example.com",
|
||||
} {
|
||||
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "%q is the stored endpoint written differently", spelling)
|
||||
}
|
||||
|
||||
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "a different port is a different endpoint")
|
||||
}
|
||||
|
||||
// The dry-run baseline exists to be compared against and discarded, so it must
|
||||
// not mint keys — the CLI's login backoff loop would otherwise log a fresh
|
||||
// "generated new Wireguard key" on every attempt.
|
||||
func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) {
|
||||
baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config")
|
||||
require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config")
|
||||
|
||||
// Everything the comparison actually looks at is still the default config.
|
||||
require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String())
|
||||
require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU)
|
||||
require.Equal(t, iface.DefaultWgPort, baseline.WgPort)
|
||||
}
|
||||
|
||||
// A stored profile can carry no identity — a mobile logout clears the keys in
|
||||
// place — so the next config write has to mint one, which is what keeps the
|
||||
// following login from dialing management with an empty key.
|
||||
func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "logged-out.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stand in for the logout, which zeroes the keys and writes the config out.
|
||||
loggedOut, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
loggedOut.PrivateKey = ""
|
||||
loggedOut.SSHKey = ""
|
||||
require.NoError(t, WriteOutConfig(path, loggedOut))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity")
|
||||
require.NotEmpty(t, cfg.SSHKey)
|
||||
|
||||
persisted, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted")
|
||||
}
|
||||
|
||||
// A config that carries no sync message version must not make the dry run
|
||||
// panic: the gate runs inside a request handler, where failing closed is the
|
||||
// worst acceptable outcome.
|
||||
func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) {
|
||||
cfg := seededConfig(t)
|
||||
require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset")
|
||||
|
||||
version := 2
|
||||
changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config")
|
||||
}
|
||||
|
||||
// Restating the certificate paths a config already holds is not a change, for
|
||||
// the same reason restating any other value is not.
|
||||
func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mtls.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ClientCertPath: "/etc/netbird/client.crt",
|
||||
ClientCertKeyPath: "/etc/netbird/client.key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
changed, err := cfg.WouldChange(ConfigInput{
|
||||
ClientCertPath: "/etc/netbird/client.crt",
|
||||
ClientCertKeyPath: "/etc/netbird/client.key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "the stored certificate paths were restated")
|
||||
|
||||
changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "a different certificate path is a change")
|
||||
}
|
||||
|
||||
// A read that lands on a missing file must not hand back keys: nothing would
|
||||
// write them down, so the caller would connect with an identity that changes on
|
||||
// the next run and registers a second peer.
|
||||
func TestReadConfigOrDefaultCarriesNoIdentity(t *testing.T) {
|
||||
cfg, err := ReadConfigOrDefault(filepath.Join(t.TempDir(), "absent.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key")
|
||||
require.Empty(t, cfg.SSHKey, "a read minted an SSH key")
|
||||
|
||||
// So the caller's own EnsureIdentity is the one that reports the work, and
|
||||
// therefore the one that triggers the write.
|
||||
generated, err := cfg.EnsureIdentity()
|
||||
require.NoError(t, err)
|
||||
require.True(t, generated, "the provisioning caller could not tell it had to persist the identity")
|
||||
}
|
||||
|
||||
// CreateInMemoryConfig is the opposite contract: its callers connect with what
|
||||
// they get back, so it does carry an identity.
|
||||
func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) {
|
||||
cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEmpty(t, cfg.PrivateKey)
|
||||
require.NotEmpty(t, cfg.SSHKey)
|
||||
}
|
||||
|
||||
// The admin panel is opened, not dialed, so its path identifies it. Comparing
|
||||
// it as a bare endpoint left a custom panel URL unable to change.
|
||||
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "panel.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
AdminURL: "https://app.example.com/netbird",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String())
|
||||
|
||||
// Equivalent spellings of the same panel are still not a change.
|
||||
for _, same := range []string{
|
||||
"https://app.example.com/netbird",
|
||||
"https://app.example.com:443/netbird",
|
||||
"https://app.example.com/netbird/",
|
||||
"https://APP.example.com/netbird",
|
||||
} {
|
||||
changed, err := cfg.WouldChange(ConfigInput{AdminURL: same})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "%q is the stored panel written differently", same)
|
||||
}
|
||||
|
||||
// A different path is a different panel, and it must be persisted.
|
||||
changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "a different panel path is a change")
|
||||
|
||||
updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted")
|
||||
}
|
||||
|
||||
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
|
||||
// which is how a profile written before apply() resolved them looks on disk.
|
||||
// It synthesizes that state: no write produces it any more.
|
||||
func unsetOnDisk(t *testing.T, path string, fields ...string) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var stored map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &stored))
|
||||
|
||||
for _, field := range fields {
|
||||
_, present := stored[field]
|
||||
require.True(t, present, "%s is not a field of the stored config", field)
|
||||
stored[field] = json.RawMessage("null")
|
||||
}
|
||||
|
||||
rewritten, err := json.Marshal(stored)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, rewritten, 0600))
|
||||
}
|
||||
|
||||
// Seven fields mean "the effective default" when they hold no value, and every
|
||||
// profile written before apply() resolved them holds them as null. Restating
|
||||
// that default is asking for no change — and the CLI restates it on every
|
||||
// `netbird up`, because a flag set through an environment variable is a flag
|
||||
// pflag reports as Changed. Judging those restatements as changes made the
|
||||
// update-settings gate refuse `netbird up` outright for a client configured
|
||||
// through the environment, which is the shape of a Kubernetes deployment.
|
||||
//
|
||||
// A login now writes those fields set, so the fixture puts the null state back
|
||||
// on disk with unsetOnDisk instead of getting it from a login.
|
||||
func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) {
|
||||
networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
|
||||
tests := []struct {
|
||||
field string
|
||||
theDefault ConfigInput
|
||||
theOtherWay ConfigInput
|
||||
}{
|
||||
{"EnableSSHRoot",
|
||||
ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}},
|
||||
{"EnableSSHSFTP",
|
||||
ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}},
|
||||
{"EnableSSHLocalPortForwarding",
|
||||
ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}},
|
||||
{"EnableSSHRemotePortForwarding",
|
||||
ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}},
|
||||
{"DisableSSHAuth",
|
||||
ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}},
|
||||
{"SSHJWTCacheTTL",
|
||||
ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}},
|
||||
{"NetworkMonitor",
|
||||
ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.field, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unset.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
unsetOnDisk(t, path, tt.field)
|
||||
|
||||
cfg, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
changed, err := cfg.WouldChange(tt.theDefault)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field)
|
||||
|
||||
// The gate still has to refuse a request that does ask for something.
|
||||
changed, err = cfg.WouldChange(tt.theOtherWay)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "asking for a non-default %s is a change", tt.field)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The verdict must not depend on where the caller got the config from. Readers
|
||||
// normalize what they hand out, but apply() signals "I filled in a default"
|
||||
// through the same bool as "the input changed something", so a config that
|
||||
// never passed through a read would otherwise report a change for an input
|
||||
// that asks for nothing.
|
||||
func TestWouldChangeNormalizesBeforeMeasuring(t *testing.T) {
|
||||
rawConfig := func(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
|
||||
cfg := &Config{WgIface: iface.WgInterfaceDefault}
|
||||
require.Nil(t, cfg.ServerSSHAllowed, "the fixture is only useful while the config is not normalized")
|
||||
require.Nil(t, cfg.EnableSSHRoot)
|
||||
require.Empty(t, cfg.IFaceBlackList)
|
||||
return cfg
|
||||
}
|
||||
|
||||
changed, err := rawConfig(t).WouldChange(ConfigInput{})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "an input carrying nothing cannot change anything")
|
||||
|
||||
changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(false)})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "the default of a field the config never held is not a change")
|
||||
|
||||
changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(true)})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "a non-default value is still a change")
|
||||
}
|
||||
|
||||
// A zero-padded port addresses the same port. The normalization itself belongs
|
||||
// to util.ServiceURLPort and is tested there; this asserts that the comparison
|
||||
// this package hands its callers inherits it.
|
||||
func TestServiceURLPortIsNormalizedNumerically(t *testing.T) {
|
||||
padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443")
|
||||
require.NoError(t, err)
|
||||
plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, SameServiceURL(padded, plain))
|
||||
}
|
||||
|
||||
// A list the profile does not have and a list the request empties are the same
|
||||
// thing: no NAT mappings, no DNS labels. The profile stores an absent list as
|
||||
// JSON null and reads it back as a nil slice, while `netbird up` sends the
|
||||
// emptied list — CleanNATExternalIPs / CleanDNSLabels — whenever the matching
|
||||
// environment variable is set to nothing, which a deployment template does by
|
||||
// default. Judging nil and empty as different made the gate refuse that start,
|
||||
// which is the very deadlock this branch exists to remove, on another field.
|
||||
func TestWouldChangeIgnoresAnEmptiedListThatWasAlreadyAbsent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lists.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL})
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := GetExistingConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, stored.NATExternalIPs, "the fixture is only useful while the stored list is absent")
|
||||
require.Nil(t, stored.DNSLabels)
|
||||
|
||||
changed, err := stored.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "emptying a NAT list the profile never had is not a change")
|
||||
|
||||
changed, err = stored.WouldChange(ConfigInput{DNSLabels: domain.List{}})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "emptying a DNS label list the profile never had is not a change")
|
||||
|
||||
// A list that does hold something still moves when the request empties it.
|
||||
withEntries, err := UpdateConfig(ConfigInput{ConfigPath: path, NATExternalIPs: []string{"1.2.3.4"}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"1.2.3.4"}, withEntries.NATExternalIPs)
|
||||
|
||||
changed, err = withEntries.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "clearing a NAT list that had an entry is a change")
|
||||
}
|
||||
@@ -313,7 +313,11 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
|
||||
}
|
||||
|
||||
profPath := filepath.Join(configDir, id.String()+".json")
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
|
||||
// Provisioned, not bare: this config goes straight to disk, and a profile
|
||||
// file with no identity is one whose first reader has to mint the keys and
|
||||
// remember to write them back. Before identity generation moved out of
|
||||
// apply() into EnsureIdentity, createNewConfig produced them here too.
|
||||
cfg, err := createProvisionedConfig(ConfigInput{ConfigPath: profPath})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new config: %w", err)
|
||||
}
|
||||
@@ -330,6 +334,19 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenameProfile changes a profile's display name. It rewrites the whole
|
||||
// profile file, not just the name: the config is read through the normalizing
|
||||
// reader, so apply()'s resolved values — the optional booleans, the interface
|
||||
// blacklist, the DNS route interval — are persisted along with the new name.
|
||||
//
|
||||
// That is deliberate. A write that skipped apply() is what left profiles on
|
||||
// disk carrying null where a value was meant, and made a diff of the config
|
||||
// compare presence instead of value. Two consequences worth knowing: the
|
||||
// platform-dependent defaults resolved here are the renaming host's
|
||||
// (ServerSSHAllowed and the network monitor differ per OS), and a profile
|
||||
// whose stored name does not survive sanitizeDisplayName now fails to rename
|
||||
// rather than being rewritten — though apply() rejects such a profile on every
|
||||
// other read too, so it was already unusable.
|
||||
func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
|
||||
displayName, err := sanitizeDisplayName(newName)
|
||||
if err != nil {
|
||||
@@ -356,17 +373,17 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e
|
||||
return ErrProfileNotFound
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(target.Path)
|
||||
// Through the reader, not a bare Unmarshal: this was the one write that
|
||||
// skipped apply(), so it copied back whatever the file held — including an
|
||||
// optional field left unset, which every other write resolves to its
|
||||
// default. Renaming a profile is a poor place to leave that behind.
|
||||
cfg, err := GetExistingConfig(target.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
cfg.Name = displayName
|
||||
|
||||
if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
|
||||
if err := WriteOutConfig(target.Path, cfg); err != nil {
|
||||
return fmt.Errorf("failed to write profile name: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -228,3 +228,27 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) {
|
||||
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
|
||||
})
|
||||
}
|
||||
|
||||
// A profile file is written here and read back by whoever connects with it, so
|
||||
// it has to carry the peer's identity. While AddProfile used the bare
|
||||
// constructor, it wrote a config with no keys: the first reader had to mint
|
||||
// them, and the paths that read without writing — a gate deciding whether to
|
||||
// refuse a request, the mobile SDKs loading a stored profile — got a config
|
||||
// that cannot connect.
|
||||
func TestAddProfileWritesAnIdentity(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := GetExistingConfig(created.Path)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEmpty(t, stored.PrivateKey, "the profile was written without a WireGuard key")
|
||||
require.NotEmpty(t, stored.SSHKey, "the profile was written without an SSH key")
|
||||
|
||||
// And the identity is the one on disk, not one minted per read.
|
||||
reread, err := GetExistingConfig(created.Path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, stored.PrivateKey, reread.PrivateKey)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,11 +201,7 @@ func (p *StunTurnProbe) probeSTUN(ctx context.Context, uri *stun.URI) (addr stri
|
||||
}
|
||||
}()
|
||||
|
||||
net, err := stdnet.NewNet(ctx, nil)
|
||||
if err != nil {
|
||||
probeErr = fmt.Errorf("new net: %w", err)
|
||||
return
|
||||
}
|
||||
net := stdnet.NewNet(ctx, nil, nil)
|
||||
|
||||
client, err := stun.DialURI(uri, &stun.DialConfig{
|
||||
Net: net,
|
||||
@@ -290,11 +286,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri
|
||||
}
|
||||
}()
|
||||
|
||||
net, err := stdnet.NewNet(ctx, nil)
|
||||
if err != nil {
|
||||
probeErr = fmt.Errorf("new net: %w", err)
|
||||
return
|
||||
}
|
||||
net := stdnet.NewNet(ctx, nil, nil)
|
||||
cfg := &turn.ClientConfig{
|
||||
STUNServerAddr: turnServerAddr,
|
||||
TURNServerAddr: turnServerAddr,
|
||||
|
||||
@@ -294,6 +294,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error {
|
||||
return fmt.Errorf("add allowed IPs for peer %s: %w", route.Peer, err)
|
||||
}
|
||||
|
||||
w.statusRecorder.AddActiveRoutePeer(route.GetHAUniqueID(), route.Peer)
|
||||
if err := w.statusRecorder.AddPeerStateRoute(route.Peer, w.handler.String(), route.GetResourceID()); err != nil {
|
||||
log.Warnf("Failed to update peer state: %v", err)
|
||||
}
|
||||
@@ -303,6 +304,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error {
|
||||
}
|
||||
|
||||
func (w *Watcher) removeAllowedIPs(route *route.Route, rsn reason) error {
|
||||
w.statusRecorder.RemoveActiveRoutePeer(route.GetHAUniqueID())
|
||||
if err := w.statusRecorder.RemovePeerStateRoute(route.Peer, w.handler.String()); err != nil {
|
||||
log.Warnf("Failed to update peer state: %v", err)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ type IPForwardingState struct {
|
||||
|
||||
// routingV4/routingV6 track whether the routing path currently holds a
|
||||
// reference, so repeated EnableRouting calls (one per network-map update)
|
||||
// hold at most one reference per family and an unpaired DisableRouting
|
||||
// can't release references held by DNAT rules.
|
||||
// hold at most one reference per family.
|
||||
routingV4 bool
|
||||
routingV6 bool
|
||||
|
||||
@@ -95,31 +94,6 @@ func (f *IPForwardingState) ReleaseRouting() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestForwarding enables the family's forwarding sysctl on first request.
|
||||
func (f *IPForwardingState) RequestForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.requestV6()
|
||||
}
|
||||
return f.requestV4()
|
||||
}
|
||||
|
||||
// ReleaseForwarding decrements the family counter. The last v6 release restores
|
||||
// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other
|
||||
// tooling (docker, k8s, libvirt).
|
||||
func (f *IPForwardingState) ReleaseForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.releaseV6()
|
||||
}
|
||||
f.releaseV4()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) requestV4() error {
|
||||
if f.v4Count == 0 {
|
||||
if err := systemops.EnableV4IPForwarding(); err != nil {
|
||||
|
||||
@@ -10,8 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request
|
||||
// releases a previously held routing-owned v6 reference without touching
|
||||
// references held by DNAT rules.
|
||||
// releases a previously held routing-owned v6 reference.
|
||||
func TestRequestRoutingV6ToV4Transition(t *testing.T) {
|
||||
f := NewIPForwardingState("wt-fwd-test")
|
||||
|
||||
@@ -25,13 +24,6 @@ func TestRequestRoutingV6ToV4Transition(t *testing.T) {
|
||||
assert.Equal(t, 1, v4, "v4 reference kept")
|
||||
assert.Equal(t, 0, v6, "routing-owned v6 reference released")
|
||||
|
||||
// A DNAT-held reference survives a v4-only routing request.
|
||||
require.NoError(t, f.RequestForwarding(true), "dnat v6 reference")
|
||||
require.NoError(t, f.RequestRouting(false), "repeat v4-only request")
|
||||
_, v6 = f.Counts()
|
||||
assert.Equal(t, 1, v6, "dnat-held v6 reference survives")
|
||||
require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference")
|
||||
|
||||
require.NoError(t, f.ReleaseRouting(), "release routing")
|
||||
v4, v6 = f.Counts()
|
||||
assert.Equal(t, 0, v4, "all v4 references released")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -406,10 +407,7 @@ func TestManagerUpdateRoutes(t *testing.T) {
|
||||
for n, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
peerPrivateKey, _ := wgtypes.GeneratePrivateKey()
|
||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: fmt.Sprintf("utun43%d", n),
|
||||
Address: wgaddr.MustParseWGAddress("100.65.65.2/24"),
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -436,8 +437,7 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen
|
||||
peerPrivateKey, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
|
||||
|
||||
opts := iface.WGIFaceOpts{
|
||||
IFaceName: interfaceName,
|
||||
|
||||
@@ -3,17 +3,13 @@ package stdnet
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
)
|
||||
|
||||
// InterfaceFilter is a function passed to ICE Agent to filter out not allowed interfaces
|
||||
// to avoid building tunnel over them.
|
||||
func InterfaceFilter(disallowList []string) func(string) bool {
|
||||
|
||||
// to avoid building tunnel over them. A nil detector probes the interface on every call,
|
||||
// which is what the callers that build one filter for their whole lifetime want.
|
||||
func InterfaceFilter(disallowList []string, detector *WGDetector) func(string) bool {
|
||||
return func(iFace string) bool {
|
||||
|
||||
if strings.HasPrefix(iFace, "lo") {
|
||||
// hardcoded loopback check to support already installed agents
|
||||
return false
|
||||
@@ -24,17 +20,8 @@ func InterfaceFilter(disallowList []string) func(string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// look for unlisted WireGuard interfaces
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
log.Debugf("trying to create a wgctrl client failed with: %v", err)
|
||||
return true
|
||||
}
|
||||
defer func() {
|
||||
_ = wg.Close()
|
||||
}()
|
||||
|
||||
_, err = wg.Device(iFace)
|
||||
return err != nil
|
||||
// look for unlisted WireGuard interfaces
|
||||
return !detector.IsWireGuard(iFace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@ type Net struct {
|
||||
}
|
||||
|
||||
// NewNetWithDiscover creates a new StdNet instance.
|
||||
func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) (*Net, error) {
|
||||
func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string, detector *WGDetector) *Net {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
n := &Net{
|
||||
interfaceFilter: InterfaceFilter(disallowList),
|
||||
interfaceFilter: InterfaceFilter(disallowList, detector),
|
||||
ctx: ctx,
|
||||
}
|
||||
// current ExternalIFaceDiscover implement in android-client https://github.dev/netbirdio/android-client
|
||||
@@ -60,20 +60,19 @@ func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover
|
||||
} else {
|
||||
n.iFaceDiscover = newMobileIFaceDiscover(iFaceDiscover)
|
||||
}
|
||||
return n, n.UpdateInterfaces()
|
||||
return n
|
||||
}
|
||||
|
||||
// NewNet creates a new StdNet instance.
|
||||
func NewNet(ctx context.Context, disallowList []string) (*Net, error) {
|
||||
func NewNet(ctx context.Context, disallowList []string, detector *WGDetector) *Net {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
n := &Net{
|
||||
return &Net{
|
||||
iFaceDiscover: pionDiscover{},
|
||||
interfaceFilter: InterfaceFilter(disallowList),
|
||||
interfaceFilter: InterfaceFilter(disallowList, detector),
|
||||
ctx: ctx,
|
||||
}
|
||||
return n, n.UpdateInterfaces()
|
||||
}
|
||||
|
||||
// resolveAddr performs DNS resolution with context support and timeout.
|
||||
@@ -122,45 +121,18 @@ func (n *Net) resolveAddr(network, address string) (netip.AddrPort, error) {
|
||||
return netip.AddrPortFrom(addrs[0], uint16(port)), nil
|
||||
}
|
||||
|
||||
// UpdateInterfaces updates the internal list of network interfaces
|
||||
// and associated addresses filtering them by name.
|
||||
// The interfaces are discovered by an external iFaceDiscover function or by a default discoverer if the external one
|
||||
// wasn't specified.
|
||||
func (n *Net) UpdateInterfaces() (err error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
|
||||
return n.updateInterfaces()
|
||||
}
|
||||
|
||||
func (n *Net) updateInterfaces() (err error) {
|
||||
allIfaces, err := n.iFaceDiscover.iFaces()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.interfaces = n.filterInterfaces(allIfaces)
|
||||
|
||||
n.lastUpdate = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interfaces returns a slice of interfaces which are available on the
|
||||
// system
|
||||
func (n *Net) Interfaces() ([]*transport.Interface, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
|
||||
if time.Since(n.lastUpdate) < updateInterval {
|
||||
return slices.Clone(n.interfaces), nil
|
||||
iFaces, err := n.freshInterfacesLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := n.updateInterfaces(); err != nil {
|
||||
return nil, fmt.Errorf("update interfaces: %w", err)
|
||||
}
|
||||
|
||||
return slices.Clone(n.interfaces), nil
|
||||
return slices.Clone(iFaces), nil
|
||||
}
|
||||
|
||||
// InterfaceByIndex returns the interface specified by index.
|
||||
@@ -171,7 +143,13 @@ func (n *Net) Interfaces() ([]*transport.Interface, error) {
|
||||
func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for _, ifc := range n.interfaces {
|
||||
|
||||
iFaces, err := n.freshInterfacesLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ifc := range iFaces {
|
||||
if ifc.Index == index {
|
||||
return ifc, nil
|
||||
}
|
||||
@@ -184,7 +162,13 @@ func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) {
|
||||
func (n *Net) InterfaceByName(name string) (*transport.Interface, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for _, ifc := range n.interfaces {
|
||||
|
||||
iFaces, err := n.freshInterfacesLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ifc := range iFaces {
|
||||
if ifc.Name == name {
|
||||
return ifc, nil
|
||||
}
|
||||
@@ -193,6 +177,31 @@ func (n *Net) InterfaceByName(name string) (*transport.Interface, error) {
|
||||
return nil, fmt.Errorf("%w: %s", transport.ErrInterfaceNotFound, name)
|
||||
}
|
||||
|
||||
func (n *Net) freshInterfacesLocked() ([]*transport.Interface, error) {
|
||||
if time.Since(n.lastUpdate) < updateInterval {
|
||||
return n.interfaces, nil
|
||||
}
|
||||
|
||||
if err := n.updateInterfacesLocked(); err != nil {
|
||||
return nil, fmt.Errorf("update interfaces: %w", err)
|
||||
}
|
||||
|
||||
return n.interfaces, nil
|
||||
}
|
||||
|
||||
func (n *Net) updateInterfacesLocked() error {
|
||||
allIFaces, err := n.iFaceDiscover.iFaces()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.interfaces = n.filterInterfaces(allIFaces)
|
||||
|
||||
n.lastUpdate = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Net) filterInterfaces(interfaces []*transport.Interface) []*transport.Interface {
|
||||
if n.interfaceFilter == nil {
|
||||
return interfaces
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package stdnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/transport/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type countingDiscover struct {
|
||||
calls int
|
||||
list []*transport.Interface
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *countingDiscover) iFaces() ([]*transport.Interface, error) {
|
||||
d.calls++
|
||||
if d.err != nil {
|
||||
return nil, d.err
|
||||
}
|
||||
return d.list, nil
|
||||
}
|
||||
|
||||
func newTestNet(t *testing.T, d iFaceDiscover) *Net {
|
||||
t.Helper()
|
||||
return &Net{
|
||||
iFaceDiscover: d,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
func testIFace(index int, name string) *transport.Interface {
|
||||
return transport.NewInterface(net.Interface{Index: index, Name: name})
|
||||
}
|
||||
|
||||
func TestNet_InterfacesDiscoversLazilyAndCaches(t *testing.T) {
|
||||
d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}}
|
||||
n := newTestNet(t, d)
|
||||
|
||||
require.Zero(t, d.calls, "construction must not discover interfaces")
|
||||
|
||||
iFaces, err := n.Interfaces()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, iFaces, 1)
|
||||
assert.Equal(t, 1, d.calls)
|
||||
|
||||
_, err = n.Interfaces()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, d.calls)
|
||||
}
|
||||
|
||||
func TestNewNet_DoesNotDiscoverAtConstruction(t *testing.T) {
|
||||
n := NewNet(context.Background(), nil, nil)
|
||||
require.NotNil(t, n)
|
||||
assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold")
|
||||
}
|
||||
|
||||
func TestNewNetWithDiscover_DoesNotDiscoverAtConstruction(t *testing.T) {
|
||||
n := NewNetWithDiscover(context.Background(), nil, nil, nil)
|
||||
require.NotNil(t, n)
|
||||
assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold")
|
||||
}
|
||||
|
||||
func TestNet_InterfacesRetryAfterDiscoveryFailure(t *testing.T) {
|
||||
discoverErr := errors.New("discover failed")
|
||||
d := &countingDiscover{err: discoverErr}
|
||||
n := newTestNet(t, d)
|
||||
|
||||
_, err := n.Interfaces()
|
||||
require.ErrorIs(t, err, discoverErr)
|
||||
|
||||
d.err = nil
|
||||
d.list = []*transport.Interface{testIFace(1, "eth0")}
|
||||
|
||||
iFaces, err := n.Interfaces()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, iFaces, 1)
|
||||
assert.Equal(t, 2, d.calls)
|
||||
}
|
||||
|
||||
func TestNet_InterfaceByNameRefreshes(t *testing.T) {
|
||||
d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}}
|
||||
n := newTestNet(t, d)
|
||||
|
||||
ifc, err := n.InterfaceByName("eth0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "eth0", ifc.Name)
|
||||
assert.Equal(t, 1, d.calls)
|
||||
|
||||
_, err = n.InterfaceByName("nope")
|
||||
require.ErrorIs(t, err, transport.ErrInterfaceNotFound)
|
||||
}
|
||||
|
||||
func TestNet_InterfaceByIndexRefreshes(t *testing.T) {
|
||||
d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}}
|
||||
n := newTestNet(t, d)
|
||||
|
||||
ifc, err := n.InterfaceByIndex(3)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "eth0", ifc.Name)
|
||||
assert.Equal(t, 1, d.calls)
|
||||
|
||||
_, err = n.InterfaceByIndex(99)
|
||||
require.ErrorIs(t, err, transport.ErrInterfaceNotFound)
|
||||
}
|
||||
|
||||
func TestNet_InterfaceLookupPropagatesDiscoveryError(t *testing.T) {
|
||||
discoverErr := errors.New("discover failed")
|
||||
n := newTestNet(t, &countingDiscover{err: discoverErr})
|
||||
|
||||
_, err := n.InterfaceByName("eth0")
|
||||
require.ErrorIs(t, err, discoverErr)
|
||||
|
||||
_, err = n.InterfaceByIndex(1)
|
||||
require.ErrorIs(t, err, discoverErr)
|
||||
}
|
||||
|
||||
func TestNet_InterfacesReturnsCopy(t *testing.T) {
|
||||
d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}}
|
||||
n := newTestNet(t, d)
|
||||
|
||||
iFaces, err := n.Interfaces()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, iFaces, 1)
|
||||
|
||||
iFaces[0] = testIFace(2, "tampered")
|
||||
|
||||
iFaces, err = n.Interfaces()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, iFaces, 1)
|
||||
assert.Equal(t, "eth0", iFaces[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package stdnet
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
)
|
||||
|
||||
// wgDetectorTTL bounds how long a cached answer is trusted. An interface rarely
|
||||
// becomes, or stops being, a WireGuard device, and the window only has to be short
|
||||
// enough that ICE does not keep gathering candidates on one that just appeared.
|
||||
const wgDetectorTTL = 1 * time.Second
|
||||
|
||||
type wgDetectorEntry struct {
|
||||
isWireGuard bool
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
// WGDetector answers whether an interface is a WireGuard device, remembering the
|
||||
// answer for a short while.
|
||||
//
|
||||
// The question is asked once per interface for every ICE agent, and an agent is
|
||||
// created per peer connection attempt, so on a large network the uncached form runs
|
||||
// constantly. Answering it means opening a wgctrl client, which builds both a kernel
|
||||
// and a userspace client and resolves the netlink family, and then a round trip that
|
||||
// usually just reports the device does not exist.
|
||||
//
|
||||
// A detector is safe for concurrent use and is meant to be shared by every agent.
|
||||
type WGDetector struct {
|
||||
ttl time.Duration
|
||||
// probe is replaced in tests; it is the call this type exists to avoid repeating.
|
||||
probe func(string) bool
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]wgDetectorEntry
|
||||
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
||||
// NewWGDetector returns a detector with the default time to live.
|
||||
func NewWGDetector() *WGDetector {
|
||||
return &WGDetector{
|
||||
ttl: wgDetectorTTL,
|
||||
probe: probeWireGuard,
|
||||
cache: make(map[string]wgDetectorEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// IsWireGuard reports whether the named interface is a WireGuard device. An interface
|
||||
// it cannot ask about is reported as not being one, which leaves it available to ICE
|
||||
// exactly as an uncached probe would.
|
||||
func (d *WGDetector) IsWireGuard(iFace string) bool {
|
||||
if d == nil {
|
||||
return probeWireGuard(iFace)
|
||||
}
|
||||
|
||||
if isWireGuard, ok := d.cached(iFace); ok {
|
||||
return isWireGuard
|
||||
}
|
||||
|
||||
result, _, _ := d.sf.Do(iFace, func() (interface{}, error) {
|
||||
// A caller that saw the entry expire may get here after another caller already
|
||||
// refreshed it and left the singleflight group.
|
||||
if isWireGuard, ok := d.cached(iFace); ok {
|
||||
return isWireGuard, nil
|
||||
}
|
||||
|
||||
isWireGuard := d.probe(iFace)
|
||||
|
||||
d.store(iFace, isWireGuard)
|
||||
|
||||
return isWireGuard, nil
|
||||
})
|
||||
return result.(bool)
|
||||
}
|
||||
|
||||
func (d *WGDetector) cached(iFace string) (isWireGuard, ok bool) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
entry, found := d.cache[iFace]
|
||||
if !found || !time.Now().Before(entry.expireAt) {
|
||||
return false, false
|
||||
}
|
||||
return entry.isWireGuard, true
|
||||
}
|
||||
|
||||
// store records an answer and drops the expired ones, so names of interfaces that came
|
||||
// and went, such as container veths, do not pile up for the life of the engine.
|
||||
func (d *WGDetector) store(iFace string, isWireGuard bool) {
|
||||
now := time.Now()
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
for name, entry := range d.cache {
|
||||
if !now.Before(entry.expireAt) {
|
||||
delete(d.cache, name)
|
||||
}
|
||||
}
|
||||
d.cache[iFace] = wgDetectorEntry{isWireGuard: isWireGuard, expireAt: now.Add(d.ttl)}
|
||||
}
|
||||
|
||||
func probeWireGuard(iFace string) bool {
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
log.Debugf("trying to create a wgctrl client failed with: %v", err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = wg.Close()
|
||||
}()
|
||||
|
||||
_, err = wg.Device(iFace)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stdnet
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newCountingDetector returns a detector whose probe records how often it ran, so a test
|
||||
// can assert on the thing this type exists for rather than on its return value alone.
|
||||
func newCountingDetector(t *testing.T, ttl time.Duration, answer bool) (*WGDetector, *atomic.Int64) {
|
||||
t.Helper()
|
||||
|
||||
var calls atomic.Int64
|
||||
d := &WGDetector{
|
||||
ttl: ttl,
|
||||
cache: make(map[string]wgDetectorEntry),
|
||||
probe: func(string) bool {
|
||||
calls.Add(1)
|
||||
return answer
|
||||
},
|
||||
}
|
||||
return d, &calls
|
||||
}
|
||||
|
||||
func TestWGDetectorAsksOncePerInterfaceWithinTheTTL(t *testing.T) {
|
||||
d, calls := newCountingDetector(t, time.Minute, true)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
assert.True(t, d.IsWireGuard("wt0"), "cached answer must not change")
|
||||
}
|
||||
assert.Equal(t, int64(1), calls.Load(), "the interface must be probed once within the TTL")
|
||||
|
||||
d.IsWireGuard("eth0")
|
||||
assert.Equal(t, int64(2), calls.Load(), "a different interface is a different question and is probed on its own")
|
||||
}
|
||||
|
||||
func TestWGDetectorReprobesAfterTheTTL(t *testing.T) {
|
||||
d, calls := newCountingDetector(t, time.Millisecond, true)
|
||||
|
||||
require.True(t, d.IsWireGuard("wt0"), "first answer")
|
||||
require.Equal(t, int64(1), calls.Load(), "first call probes")
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
require.True(t, d.IsWireGuard("wt0"), "answer after expiry")
|
||||
assert.Equal(t, int64(2), calls.Load(), "an expired entry must be probed again")
|
||||
}
|
||||
|
||||
func TestWGDetectorCollapsesConcurrentProbes(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
release := make(chan struct{})
|
||||
d := &WGDetector{
|
||||
ttl: time.Minute,
|
||||
cache: make(map[string]wgDetectorEntry),
|
||||
probe: func(string) bool {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d.IsWireGuard("wt0")
|
||||
}()
|
||||
}
|
||||
|
||||
// The sleep only lets the callers pile up on the blocked probe so the collapse is
|
||||
// exercised. The count does not depend on it: a caller that arrives after the probe
|
||||
// finished finds the fresh entry, either before or inside the singleflight group.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int64(1), calls.Load(), "concurrent callers must share one probe")
|
||||
}
|
||||
|
||||
func TestWGDetectorNilProbesEveryTime(t *testing.T) {
|
||||
var d *WGDetector
|
||||
// A nil detector keeps the uncached behaviour, which is what the callers that build one
|
||||
// filter for their whole lifetime rely on. It must not panic.
|
||||
assert.NotPanics(t, func() { d.IsWireGuard("definitely-not-an-interface-0") },
|
||||
"a nil detector must fall back to probing")
|
||||
}
|
||||
|
||||
func TestInterfaceFilter(t *testing.T) {
|
||||
wgDetector, calls := newCountingDetector(t, time.Minute, true)
|
||||
plainDetector, _ := newCountingDetector(t, time.Minute, false)
|
||||
|
||||
t.Run("loopback is rejected without probing", func(t *testing.T) {
|
||||
filter := InterfaceFilter(nil, wgDetector)
|
||||
assert.False(t, filter("lo"), "loopback must never be offered to ICE")
|
||||
assert.Equal(t, int64(0), calls.Load(), "a name settled by prefix must not reach the probe")
|
||||
})
|
||||
|
||||
t.Run("a disallowed interface is rejected without probing", func(t *testing.T) {
|
||||
if runtime.GOOS == "ios" {
|
||||
t.Skip("the disallow list is not applied on iOS")
|
||||
}
|
||||
filter := InterfaceFilter([]string{"wt"}, wgDetector)
|
||||
assert.False(t, filter("wt0"), "a blacklisted interface must be rejected")
|
||||
assert.Equal(t, int64(0), calls.Load(), "a name settled by the disallow list must not reach the probe")
|
||||
})
|
||||
|
||||
t.Run("an unlisted WireGuard interface is rejected", func(t *testing.T) {
|
||||
filter := InterfaceFilter(nil, wgDetector)
|
||||
assert.False(t, filter("somewg0"), "a WireGuard interface must not be used to build a tunnel")
|
||||
})
|
||||
|
||||
t.Run("an ordinary interface is allowed", func(t *testing.T) {
|
||||
filter := InterfaceFilter(nil, plainDetector)
|
||||
assert.True(t, filter("eth0"), "a plain interface must remain available to ICE")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInterfaceFilterSharesOneProbeAcrossFilters(t *testing.T) {
|
||||
d, calls := newCountingDetector(t, time.Minute, false)
|
||||
|
||||
// Every ICE agent builds its own filter, twice, and each one is asked about every
|
||||
// interface. Sharing the detector is what keeps that from repeating the probe.
|
||||
for i := 0; i < 10; i++ {
|
||||
filter := InterfaceFilter(nil, d)
|
||||
require.True(t, filter("eth0"), "a plain interface stays allowed")
|
||||
require.True(t, filter("eth1"), "a plain interface stays allowed")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(2), calls.Load(), "one probe per interface, not per filter")
|
||||
}
|
||||
|
||||
func TestWGDetectorDropsExpiredEntries(t *testing.T) {
|
||||
d, _ := newCountingDetector(t, time.Millisecond, false)
|
||||
|
||||
for _, name := range []string{"veth1", "veth2", "veth3"} {
|
||||
d.IsWireGuard(name)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
d.IsWireGuard("eth0")
|
||||
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
assert.Len(t, d.cache, 1, "expired entries of vanished interfaces must be dropped")
|
||||
assert.Contains(t, d.cache, "eth0", "the fresh answer must be kept")
|
||||
}
|
||||
@@ -21,8 +21,16 @@ const (
|
||||
latestVersion = "latest"
|
||||
)
|
||||
|
||||
const (
|
||||
modeUndecided updateMode = iota
|
||||
modeDownloadOnly
|
||||
modeManaged
|
||||
)
|
||||
|
||||
var errNoUpdateState = errors.New("no update state found")
|
||||
|
||||
type updateMode int
|
||||
|
||||
type UpdateState struct {
|
||||
PreUpdateVersion string
|
||||
TargetVersion string
|
||||
@@ -36,8 +44,9 @@ type Manager struct {
|
||||
statusRecorder *peer.Status
|
||||
stateManager *statemanager.Manager
|
||||
|
||||
downloadOnly bool // true when no enforcement from management; notifies UI to download latest
|
||||
forceUpdate bool // true when management sets AlwaysUpdate; skips UI interaction and installs directly
|
||||
mode updateMode
|
||||
modeGen uint64
|
||||
forceUpdate bool // true when management sets AlwaysUpdate; skips UI interaction and installs directly
|
||||
|
||||
lastTrigger time.Time
|
||||
mgmUpdateChan chan struct{}
|
||||
@@ -54,7 +63,7 @@ type Manager struct {
|
||||
pendingVersion *v.Version
|
||||
|
||||
// updateMutex protects update, expectedVersion, updateToLatestVersion,
|
||||
// downloadOnly, forceUpdate, pendingVersion, and lastTrigger fields
|
||||
// mode, modeGen, forceUpdate, pendingVersion, and lastTrigger fields
|
||||
updateMutex sync.Mutex
|
||||
|
||||
// installMutex and installing guard against concurrent installation attempts
|
||||
@@ -76,7 +85,6 @@ func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager)
|
||||
updateChannel: make(chan struct{}, 1),
|
||||
currentVersion: version.NetbirdVersion(),
|
||||
update: version.NewUpdate("nb/client"),
|
||||
downloadOnly: true,
|
||||
autoUpdateSupported: isAutoUpdateSupported,
|
||||
}
|
||||
|
||||
@@ -151,11 +159,7 @@ func (m *Manager) Start(ctx context.Context) {
|
||||
|
||||
func (m *Manager) SetDownloadOnly() {
|
||||
m.updateMutex.Lock()
|
||||
m.downloadOnly = true
|
||||
m.forceUpdate = false
|
||||
m.expectedVersion = nil
|
||||
m.updateToLatestVersion = false
|
||||
m.lastTrigger = time.Time{}
|
||||
m.setModeLocked(modeDownloadOnly)
|
||||
m.updateMutex.Unlock()
|
||||
|
||||
select {
|
||||
@@ -169,6 +173,7 @@ func (m *Manager) SetVersion(expectedVersion string, forceUpdate bool) {
|
||||
|
||||
if !m.autoUpdateSupported() {
|
||||
log.Warnf("auto-update not supported on this platform")
|
||||
m.SetDownloadOnly()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,30 +182,32 @@ func (m *Manager) SetVersion(expectedVersion string, forceUpdate bool) {
|
||||
|
||||
if expectedVersion == "" {
|
||||
log.Errorf("empty expected version provided")
|
||||
m.expectedVersion = nil
|
||||
m.updateToLatestVersion = false
|
||||
m.downloadOnly = true
|
||||
m.setModeLocked(modeDownloadOnly)
|
||||
return
|
||||
}
|
||||
|
||||
if expectedVersion == latestVersion {
|
||||
m.updateToLatestVersion = true
|
||||
m.expectedVersion = nil
|
||||
} else {
|
||||
expectedSemVer, err := v.NewVersion(expectedVersion)
|
||||
var expectedSemVer *v.Version
|
||||
if expectedVersion != latestVersion {
|
||||
parsed, err := v.NewVersion(expectedVersion)
|
||||
if err != nil {
|
||||
log.Errorf("error parsing version: %v", err)
|
||||
log.Errorf("error parsing version, switching to download-only: %v", err)
|
||||
m.setModeLocked(modeDownloadOnly)
|
||||
select {
|
||||
case m.mgmUpdateChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if m.expectedVersion != nil && m.expectedVersion.Equal(expectedSemVer) {
|
||||
return
|
||||
}
|
||||
m.expectedVersion = expectedSemVer
|
||||
m.updateToLatestVersion = false
|
||||
expectedSemVer = parsed
|
||||
}
|
||||
|
||||
m.lastTrigger = time.Time{}
|
||||
m.downloadOnly = false
|
||||
if m.sameDirectiveLocked(expectedSemVer, forceUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
m.setModeLocked(modeManaged)
|
||||
m.expectedVersion = expectedSemVer
|
||||
m.updateToLatestVersion = expectedSemVer == nil
|
||||
m.forceUpdate = forceUpdate
|
||||
|
||||
select {
|
||||
@@ -209,6 +216,13 @@ func (m *Manager) SetVersion(expectedVersion string, forceUpdate bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) ResetMode() {
|
||||
m.updateMutex.Lock()
|
||||
defer m.updateMutex.Unlock()
|
||||
|
||||
m.setModeLocked(modeUndecided)
|
||||
}
|
||||
|
||||
// Install triggers the installation of the pending version. It is called when the user clicks the install button in the UI.
|
||||
func (m *Manager) Install(ctx context.Context) error {
|
||||
if !m.autoUpdateSupported() {
|
||||
@@ -255,12 +269,17 @@ func (m *Manager) NotifyUI() {
|
||||
m.updateMutex.Unlock()
|
||||
return
|
||||
}
|
||||
downloadOnly := m.downloadOnly
|
||||
mode := m.mode
|
||||
gen := m.modeGen
|
||||
pendingVersion := m.pendingVersion
|
||||
latestVersion := m.update.LatestVersion()
|
||||
m.updateMutex.Unlock()
|
||||
|
||||
if downloadOnly {
|
||||
if mode == modeUndecided {
|
||||
return
|
||||
}
|
||||
|
||||
if mode == modeDownloadOnly {
|
||||
if latestVersion == nil {
|
||||
return
|
||||
}
|
||||
@@ -268,6 +287,9 @@ func (m *Manager) NotifyUI() {
|
||||
if err != nil || currentVersion.GreaterThanOrEqual(latestVersion) {
|
||||
return
|
||||
}
|
||||
if m.modeChanged(gen) {
|
||||
return
|
||||
}
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
@@ -278,7 +300,7 @@ func (m *Manager) NotifyUI() {
|
||||
return
|
||||
}
|
||||
|
||||
if pendingVersion != nil {
|
||||
if pendingVersion != nil && !m.modeChanged(gen) {
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
@@ -343,13 +365,18 @@ func (m *Manager) handleUpdate(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
downloadOnly := m.downloadOnly
|
||||
mode := m.mode
|
||||
gen := m.modeGen
|
||||
forceUpdate := m.forceUpdate
|
||||
curLatestVersion := m.update.LatestVersion()
|
||||
|
||||
switch {
|
||||
case mode == modeUndecided:
|
||||
log.Tracef("auto-update mode not decided yet")
|
||||
m.updateMutex.Unlock()
|
||||
return
|
||||
// Download-only mode or resolve "latest" to actual version
|
||||
case downloadOnly, m.updateToLatestVersion:
|
||||
case mode == modeDownloadOnly, m.updateToLatestVersion:
|
||||
if curLatestVersion == nil {
|
||||
log.Tracef("latest version not fetched yet")
|
||||
m.updateMutex.Unlock()
|
||||
@@ -374,12 +401,17 @@ func (m *Manager) handleUpdate(ctx context.Context) {
|
||||
m.lastTrigger = time.Now()
|
||||
log.Infof("new version available: %s", updateVersion)
|
||||
|
||||
if !downloadOnly && !forceUpdate {
|
||||
if mode == modeManaged && !forceUpdate {
|
||||
m.pendingVersion = updateVersion
|
||||
}
|
||||
m.updateMutex.Unlock()
|
||||
|
||||
if downloadOnly {
|
||||
if m.modeChanged(gen) {
|
||||
log.Debugf("auto-update mode changed while checking %s, discarding", updateVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if mode == modeDownloadOnly {
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
@@ -406,6 +438,33 @@ func (m *Manager) handleUpdate(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) modeChanged(gen uint64) bool {
|
||||
m.updateMutex.Lock()
|
||||
defer m.updateMutex.Unlock()
|
||||
|
||||
return m.modeGen != gen
|
||||
}
|
||||
|
||||
func (m *Manager) sameDirectiveLocked(expectedVersion *v.Version, forceUpdate bool) bool {
|
||||
if m.mode != modeManaged || m.forceUpdate != forceUpdate {
|
||||
return false
|
||||
}
|
||||
if expectedVersion == nil {
|
||||
return m.updateToLatestVersion
|
||||
}
|
||||
return m.expectedVersion != nil && m.expectedVersion.Equal(expectedVersion)
|
||||
}
|
||||
|
||||
func (m *Manager) setModeLocked(mode updateMode) {
|
||||
m.mode = mode
|
||||
m.modeGen++
|
||||
m.forceUpdate = false
|
||||
m.expectedVersion = nil
|
||||
m.updateToLatestVersion = false
|
||||
m.pendingVersion = nil
|
||||
m.lastTrigger = time.Time{}
|
||||
}
|
||||
|
||||
func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error {
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// On Linux, only Mode 1 (downloadOnly) is supported.
|
||||
// SetVersion is a no-op because auto-update installation is not supported.
|
||||
// SetVersion falls back to download-only because auto-update installation is not supported.
|
||||
|
||||
func Test_LatestVersion_Linux(t *testing.T) {
|
||||
testMatrix := []struct {
|
||||
@@ -70,7 +70,7 @@ func Test_LatestVersion_Linux(t *testing.T) {
|
||||
t.Errorf("%s: Initial version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver)
|
||||
}
|
||||
|
||||
mockUpdate.latestVersion = c.latestVersion
|
||||
mockUpdate.setLatestVersion(c.latestVersion)
|
||||
mockUpdate.onUpdate()
|
||||
|
||||
ver, enforced = waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
@@ -89,22 +89,24 @@ func Test_LatestVersion_Linux(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetVersion_NoOp_Linux(t *testing.T) {
|
||||
// On Linux, SetVersion should be a no-op — no events fired
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-noop.json")
|
||||
func Test_SetVersion_FallsBackToDownloadOnly_Linux(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-fallback.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
||||
m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.5"))}
|
||||
m.currentVersion = "1.0.0"
|
||||
m.Start(context.Background())
|
||||
m.SetVersion("1.0.1", false)
|
||||
|
||||
ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "" {
|
||||
t.Errorf("SetVersion should be a no-op on Linux, but got event with version %s", ver)
|
||||
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.5" {
|
||||
t.Fatalf("expected download-only event for fetched 1.0.5, got %q", ver)
|
||||
}
|
||||
if enforced {
|
||||
t.Error("Linux fallback must never have enforced metadata")
|
||||
}
|
||||
|
||||
m.Stop()
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v "github.com/hashicorp/go-version"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
)
|
||||
|
||||
func Test_UndecidedMode_SuppressesNotification(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-undecided.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
mockUpdate := &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = mockUpdate
|
||||
m.currentVersion = "1.0.0"
|
||||
m.Start(context.Background())
|
||||
defer m.Stop()
|
||||
|
||||
mockUpdate.onUpdate()
|
||||
if ver, _ := waitForUpdateEvent(sub, 300*time.Millisecond); ver != "" {
|
||||
t.Fatalf("undecided mode must not publish, got %q", ver)
|
||||
}
|
||||
|
||||
m.NotifyUI()
|
||||
if ver, _ := waitForUpdateEvent(sub, 300*time.Millisecond); ver != "" {
|
||||
t.Fatalf("NotifyUI in undecided mode must not publish, got %q", ver)
|
||||
}
|
||||
|
||||
m.SetDownloadOnly()
|
||||
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.1" {
|
||||
t.Fatalf("expected download-only event for 1.0.1, got %q", ver)
|
||||
}
|
||||
if enforced {
|
||||
t.Error("download-only event must not carry enforced metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ResetMode_ReturnsToUndecided(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-reset.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
mockUpdate := &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = mockUpdate
|
||||
m.currentVersion = "1.0.0"
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
m.Start(context.Background())
|
||||
defer m.Stop()
|
||||
|
||||
m.SetVersion("1.0.1", false)
|
||||
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.1" || !enforced {
|
||||
t.Fatalf("expected enforced event for 1.0.1, got %q enforced=%v", ver, enforced)
|
||||
}
|
||||
|
||||
m.ResetMode()
|
||||
|
||||
mockUpdate.onUpdate()
|
||||
if ver, _ := waitForUpdateEvent(sub, 300*time.Millisecond); ver != "" {
|
||||
t.Fatalf("reset mode must not publish on fetch, got %q", ver)
|
||||
}
|
||||
|
||||
m.NotifyUI()
|
||||
if ver, _ := waitForUpdateEvent(sub, 300*time.Millisecond); ver != "" {
|
||||
t.Fatalf("NotifyUI after reset must not publish, got %q", ver)
|
||||
}
|
||||
|
||||
if err := m.Install(context.Background()); err == nil {
|
||||
t.Fatal("Install after reset must fail without a pending version")
|
||||
}
|
||||
|
||||
m.SetVersion("1.0.1", false)
|
||||
ver, enforced = waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.1" || !enforced {
|
||||
t.Fatalf("expected enforced event again after reset, got %q enforced=%v", ver, enforced)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetDownloadOnly_ClearsPendingVersion(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-pending.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
||||
m.currentVersion = "1.0.0"
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
m.Start(context.Background())
|
||||
defer m.Stop()
|
||||
|
||||
m.SetVersion("1.0.1", false)
|
||||
if ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond); ver != "1.0.1" || !enforced {
|
||||
t.Fatalf("expected enforced event for 1.0.1, got %q enforced=%v", ver, enforced)
|
||||
}
|
||||
|
||||
m.SetDownloadOnly()
|
||||
if ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond); ver != "1.0.1" || enforced {
|
||||
t.Fatalf("expected download-only event for 1.0.1, got %q enforced=%v", ver, enforced)
|
||||
}
|
||||
|
||||
if err := m.Install(context.Background()); err == nil {
|
||||
t.Fatal("Install in download-only mode must not install the staged managed version")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ResetMode_SilencesStaleForceDirective(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-force-reset.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
mockUpdate := &versionUpdateMock{}
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = mockUpdate
|
||||
m.currentVersion = "1.0.0"
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
m.Start(context.Background())
|
||||
defer m.Stop()
|
||||
|
||||
// Management enforces "latest" before the fetcher has reported any version,
|
||||
// so nothing can be installed while the engine is still up.
|
||||
m.SetVersion(latestVersion, true)
|
||||
if event := waitForAnyEvent(sub, 300*time.Millisecond); event != nil {
|
||||
t.Fatalf("no event expected before the latest version is known, got %v", event)
|
||||
}
|
||||
|
||||
// The engine stop resets the mode. A release published afterwards must not
|
||||
// trigger the stale forced install or any notification.
|
||||
m.ResetMode()
|
||||
mockUpdate.setLatestVersion(v.Must(v.NewSemver("1.0.1")))
|
||||
mockUpdate.onUpdate()
|
||||
if event := waitForAnyEvent(sub, 300*time.Millisecond); event != nil {
|
||||
t.Fatalf("stale force directive must stay silent after reset, got %v", event)
|
||||
}
|
||||
|
||||
m.SetVersion("1.0.1", false)
|
||||
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.1" || !enforced {
|
||||
t.Fatalf("expected enforced event after a fresh directive, got %q enforced=%v", ver, enforced)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetVersion_MalformedFallsBackToDownloadOnly(t *testing.T) {
|
||||
tmpFile := path.Join(t.TempDir(), "update-test-malformed.json")
|
||||
recorder := peer.NewRecorder("")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
defer recorder.UnsubscribeFromEvents(sub)
|
||||
|
||||
m := NewManager(recorder, statemanager.New(tmpFile))
|
||||
m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
||||
m.currentVersion = "1.0.0"
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
m.Start(context.Background())
|
||||
defer m.Stop()
|
||||
|
||||
m.SetVersion("not-a-version", false)
|
||||
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
if ver != "1.0.1" {
|
||||
t.Fatalf("expected download-only event for 1.0.1 after malformed version, got %q", ver)
|
||||
}
|
||||
if enforced {
|
||||
t.Error("malformed version fallback must not carry enforced metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetVersion_ForceChangeAppliesWithSameVersion(t *testing.T) {
|
||||
m := NewManager(peer.NewRecorder(""), statemanager.New(path.Join(t.TempDir(), "update-test-force-change.json")))
|
||||
m.update = &versionUpdateMock{}
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
|
||||
m.SetVersion("1.0.1", false)
|
||||
m.SetVersion("1.0.1", true)
|
||||
|
||||
m.updateMutex.Lock()
|
||||
defer m.updateMutex.Unlock()
|
||||
if !m.forceUpdate {
|
||||
t.Fatal("enabling force update without a version change must take effect")
|
||||
}
|
||||
if m.expectedVersion == nil || m.expectedVersion.String() != "1.0.1" {
|
||||
t.Fatalf("expected version 1.0.1 to stay set, got %v", m.expectedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetVersion_RepeatedDirectiveKeepsMode(t *testing.T) {
|
||||
m := NewManager(peer.NewRecorder(""), statemanager.New(path.Join(t.TempDir(), "update-test-repeat.json")))
|
||||
m.update = &versionUpdateMock{}
|
||||
m.autoUpdateSupported = func() bool { return true }
|
||||
|
||||
for _, expected := range []string{"1.0.1", latestVersion} {
|
||||
m.SetVersion(expected, false)
|
||||
m.updateMutex.Lock()
|
||||
gen := m.modeGen
|
||||
m.updateMutex.Unlock()
|
||||
|
||||
m.SetVersion(expected, false)
|
||||
m.updateMutex.Lock()
|
||||
repeatedGen := m.modeGen
|
||||
m.updateMutex.Unlock()
|
||||
|
||||
if repeatedGen != gen {
|
||||
t.Errorf("repeating the %q directive must not reset the mode", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func Test_LatestVersion(t *testing.T) {
|
||||
t.Errorf("%s: Initial update version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver)
|
||||
}
|
||||
|
||||
mockUpdate.latestVersion = c.latestVersion
|
||||
mockUpdate.setLatestVersion(c.latestVersion)
|
||||
mockUpdate.onUpdate()
|
||||
|
||||
ver, _ = waitForUpdateEvent(sub, 500*time.Millisecond)
|
||||
|
||||
@@ -2,21 +2,24 @@ package updater
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
v "github.com/hashicorp/go-version"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type versionUpdateMock struct {
|
||||
latestVersion *v.Version
|
||||
onUpdate func()
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (m versionUpdateMock) StopWatch() {}
|
||||
func (m *versionUpdateMock) StopWatch() {}
|
||||
|
||||
func (m versionUpdateMock) SetDaemonVersion(newVersion string) bool {
|
||||
func (m *versionUpdateMock) SetDaemonVersion(newVersion string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -24,11 +27,19 @@ func (m *versionUpdateMock) SetOnUpdateListener(updateFn func()) {
|
||||
m.onUpdate = updateFn
|
||||
}
|
||||
|
||||
func (m versionUpdateMock) LatestVersion() *v.Version {
|
||||
func (m *versionUpdateMock) LatestVersion() *v.Version {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.latestVersion
|
||||
}
|
||||
|
||||
func (m versionUpdateMock) StartFetcher() {}
|
||||
func (m *versionUpdateMock) StartFetcher() {}
|
||||
|
||||
func (m *versionUpdateMock) setLatestVersion(version *v.Version) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.latestVersion = version
|
||||
}
|
||||
|
||||
// waitForUpdateEvent waits for a new_version_available event, returns the version string or "" on timeout.
|
||||
func waitForUpdateEvent(sub *peer.EventSubscription, timeout time.Duration) (version string, enforced bool) {
|
||||
@@ -54,3 +65,20 @@ func waitForUpdateEvent(sub *peer.EventSubscription, timeout time.Duration) (ver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForAnyEvent returns the first published event of any kind, or nil on timeout.
|
||||
// Unlike waitForUpdateEvent it also catches the install-progress events, so a test
|
||||
// can assert that a forced install never started.
|
||||
func waitForAnyEvent(sub *peer.EventSubscription, timeout time.Duration) *cProto.SystemEvent {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case event, ok := <-sub.Events():
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user