mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 08:21:30 +02:00
Compare commits
1 Commits
dns-lazy-c
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9effcf4c |
@@ -247,9 +247,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ const (
|
||||
// - Handling connection establishment based on peer signaling
|
||||
//
|
||||
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||
// DNS warm-up path can call it without contending on the engine mutex.
|
||||
type ConnMgr struct {
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
@@ -44,10 +42,6 @@ type ConnMgr struct {
|
||||
rosenpassEnabled bool
|
||||
|
||||
lazyConnMgr *manager.Manager
|
||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||
// engine loop (ActivatePeer). Writers hold it in addition to
|
||||
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||
lazyConnMgrMu sync.RWMutex
|
||||
|
||||
wg sync.WaitGroup
|
||||
lazyCtx context.Context
|
||||
@@ -244,20 +238,12 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
|
||||
conn.Log.Infof("removed peer from lazy conn manager")
|
||||
}
|
||||
|
||||
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
|
||||
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
|
||||
// and the manager itself is internally synchronized, so callers outside the
|
||||
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
|
||||
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
||||
e.lazyConnMgrMu.RLock()
|
||||
lazyConnMgr := e.lazyConnMgr
|
||||
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||
e.lazyConnMgrMu.RUnlock()
|
||||
if !started {
|
||||
if !e.isStartedWithLazyMgr() {
|
||||
return
|
||||
}
|
||||
|
||||
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if err := conn.Open(ctx); err != nil {
|
||||
conn.Log.Errorf("failed to open connection: %v", err)
|
||||
}
|
||||
@@ -282,21 +268,16 @@ func (e *ConnMgr) Close() {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
}
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||
|
||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
@@ -335,10 +316,7 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
)
|
||||
|
||||
func TestResolveLazyForce(t *testing.T) {
|
||||
@@ -49,58 +38,3 @@ func TestResolveLazyForce(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockLazyWGIface struct{}
|
||||
|
||||
func (mockLazyWGIface) RemovePeer(string) error { return nil }
|
||||
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||
return nil
|
||||
}
|
||||
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
|
||||
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
|
||||
func (mockLazyWGIface) MTU() uint16 { return 1280 }
|
||||
|
||||
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
|
||||
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
|
||||
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
|
||||
// still requires engine.syncMsgMux for safety.
|
||||
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvLazyConn, "on")
|
||||
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
|
||||
|
||||
conn := newTestPeerConn(t, "peerA")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
connMgr.Start(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
connMgr.ActivatePeer(ctx, conn)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Let the activators spin against the started manager, then tear it down
|
||||
// underneath them and let them spin against the stopped manager.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
connMgr.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -37,43 +36,7 @@ type resolver interface {
|
||||
// record is left alone (it points at something outside our mesh, e.g.
|
||||
// a non-peer upstream).
|
||||
type PeerConnectivity interface {
|
||||
IsConnectedByIP(ip netip.Addr) (known, connected bool)
|
||||
}
|
||||
|
||||
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
|
||||
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
|
||||
// disconnected) starts connecting at DNS-resolution time rather than racing the
|
||||
// client's first request packet. nil disables warm-up.
|
||||
type PeerActivator interface {
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
|
||||
// until one is connected or ctx (a short per-query budget) expires. It is a
|
||||
// fast no-op for unknown or already-connected addresses.
|
||||
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLazyWarmupTimeout = 2 * time.Second
|
||||
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
|
||||
)
|
||||
|
||||
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
|
||||
// lazy-connection peer a DNS answer points at. Tunable via
|
||||
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
|
||||
func lazyWarmupTimeoutFromEnv() time.Duration {
|
||||
v := os.Getenv(envLazyWarmupTimeout)
|
||||
if v == "" {
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
if d <= 0 {
|
||||
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
return d
|
||||
IsConnectedByIP(ip string) (known, connected bool)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
@@ -88,12 +51,6 @@ type Resolver struct {
|
||||
// filter and preserves the legacy "return whatever is registered"
|
||||
// behaviour for callers that never wire a status source.
|
||||
peerConn PeerConnectivity
|
||||
// peerActivator, when non-nil, is called at resolution time to warm the
|
||||
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
|
||||
peerActivator PeerActivator
|
||||
// warmupTimeout is the per-query budget for the lazy-connection warm-up
|
||||
// wait, resolved from the environment once at construction time.
|
||||
warmupTimeout time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -102,12 +59,11 @@ type Resolver struct {
|
||||
func NewResolver() *Resolver {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Resolver{
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +76,6 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||
d.peerConn = p
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
|
||||
// disable. Safe to call multiple times; the latest value wins.
|
||||
func (d *Resolver) SetPeerActivator(a PeerActivator) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.peerActivator = a
|
||||
}
|
||||
|
||||
func (d *Resolver) MatchSubdomains() bool {
|
||||
return true
|
||||
}
|
||||
@@ -174,9 +122,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
result := d.lookupRecords(logger, question)
|
||||
// Warm before filtering: activation flips a lazily-idle target to connected,
|
||||
// which then lets it survive the disconnected-peer filter below.
|
||||
d.warmLazyPeers(question, result.records)
|
||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
@@ -550,8 +495,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
kept := make([]dns.RR, 0, len(records))
|
||||
var dropped int
|
||||
for _, rr := range records {
|
||||
ip, ok := extractRecordAddr(rr)
|
||||
if !ok {
|
||||
ip := extractRecordIP(rr)
|
||||
if ip == "" {
|
||||
kept = append(kept, rr)
|
||||
continue
|
||||
}
|
||||
@@ -573,54 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
return kept
|
||||
}
|
||||
|
||||
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
||||
// answer points at and waits briefly for one to connect, so the caller's first
|
||||
// request doesn't race the connection establishment. Warm-up is scoped to
|
||||
// match-only (non-authoritative) zones — the synthesized private-service zones
|
||||
// and user-created zones whose records point at specific peers. The account's
|
||||
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
|
||||
// otherwise resolving any peer's name would wake its idle connection, defeating
|
||||
// laziness mesh-wide. No-op when no activator is wired (lazy connections
|
||||
// disabled) or the answer carries no peer IPs.
|
||||
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
|
||||
d.mu.RLock()
|
||||
activator := d.peerActivator
|
||||
var nonAuth, found bool
|
||||
if activator != nil {
|
||||
nonAuth, found = d.findZone(question.Name)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
if activator == nil || !found || !nonAuth {
|
||||
return
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, rr := range records {
|
||||
if addr, ok := extractRecordAddr(rr); ok {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
|
||||
defer cancel()
|
||||
activator.ActivatePeersByIP(ctx, addrs)
|
||||
}
|
||||
|
||||
// extractRecordAddr returns the IP address carried by an A or AAAA record.
|
||||
// ok is false for any other record type or a record with no address.
|
||||
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
|
||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||
// an A or AAAA record, or "" for any other record type.
|
||||
func extractRecordIP(rr dns.RR) string {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
addr, ok := netip.AddrFromSlice(r.A)
|
||||
return addr.Unmap(), ok
|
||||
if r.A == nil {
|
||||
return ""
|
||||
}
|
||||
return r.A.String()
|
||||
case *dns.AAAA:
|
||||
addr, ok := netip.AddrFromSlice(r.AAAA)
|
||||
return addr.Unmap(), ok
|
||||
if r.AAAA == nil {
|
||||
return ""
|
||||
}
|
||||
return r.AAAA.String()
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
return ""
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
|
||||
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
|
||||
byIP map[string]struct{ known, connected bool }
|
||||
}
|
||||
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
v, ok := m.byIP[ip.String()]
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
v, ok := m.byIP[ip]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// recordingActivator records the addresses it was asked to warm and returns
|
||||
// immediately, so ServeDNS is not blocked by the test.
|
||||
type recordingActivator struct {
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
addrs []netip.Addr
|
||||
}
|
||||
|
||||
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.called = true
|
||||
r.addrs = append(r.addrs, addrs...)
|
||||
}
|
||||
|
||||
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
|
||||
t.Helper()
|
||||
var resp *dns.Msg
|
||||
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
|
||||
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
|
||||
return resp
|
||||
}
|
||||
|
||||
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
|
||||
// the synthesized private-service zones arrive in.
|
||||
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
|
||||
t.Helper()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: zone,
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
}
|
||||
|
||||
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.True(t, act.called, "activator must be invoked for a service-zone A answer")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the answer's peer IP")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
|
||||
// With no activator wired the resolver behaves exactly as before.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must still answer without an activator")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
|
||||
// A query that resolves to nothing must not invoke the activator (no IPs).
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud",
|
||||
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
serveA(t, resolver, "absent.proxy.netbird.cloud.")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked when there is no answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
|
||||
// The account's peer zone is authoritative; resolving a peer's name there
|
||||
// must not wake its lazy connection — warm-up is scoped to match-only
|
||||
// (non-authoritative) zones such as the synthesized private-service zones.
|
||||
rec := nbdns.SimpleRecord{Name: "peer.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "netbird.cloud",
|
||||
Records: []nbdns.SimpleRecord{rec},
|
||||
}})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
|
||||
}
|
||||
|
||||
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
envSet bool
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "unset uses default", want: defaultLazyWarmupTimeout},
|
||||
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
|
||||
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envSet {
|
||||
t.Setenv(envLazyWarmupTimeout, tt.value)
|
||||
}
|
||||
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
|
||||
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecordAddr(t *testing.T) {
|
||||
t.Run("A record yields unmapped v4", func(t *testing.T) {
|
||||
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
|
||||
// miekg/dns stores after parsing an A record; the extracted address
|
||||
// must compare equal to a plain v4 netip.Addr.
|
||||
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
|
||||
require.True(t, ok)
|
||||
assert.True(t, addr.Is4())
|
||||
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
|
||||
})
|
||||
|
||||
t.Run("AAAA record yields v6", func(t *testing.T) {
|
||||
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
|
||||
})
|
||||
|
||||
t.Run("A record without address", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.A{})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("non-address record", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/local"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -93,11 +92,6 @@ func (m *MockServer) SetFirewall(Firewall) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
|
||||
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||
func (m *MockServer) BeginBatch() {
|
||||
// Mock implementation - no-op
|
||||
|
||||
@@ -82,7 +82,6 @@ type Server interface {
|
||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||
SetRouteSources(selected, active func() route.HAMap)
|
||||
SetFirewall(Firewall)
|
||||
SetPeerActivator(local.PeerActivator)
|
||||
}
|
||||
|
||||
type nsGroupsByDomain struct {
|
||||
@@ -492,13 +491,6 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
|
||||
// resolver. Injected after the connection manager exists (it does not at
|
||||
// DNS-server construction time). Pass nil to disable.
|
||||
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
|
||||
s.localResolver.SetPeerActivator(a)
|
||||
}
|
||||
|
||||
// Stop stops the server
|
||||
func (s *DefaultServer) Stop() {
|
||||
s.ctxCancel()
|
||||
@@ -1443,11 +1435,11 @@ type localPeerConnectivity struct {
|
||||
|
||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
if l.status == nil {
|
||||
return false, false
|
||||
}
|
||||
state, ok := l.status.PeerStateByIP(ip.String())
|
||||
state, ok := l.status.PeerStateByIP(ip)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
const dnsActivationPollInterval = 50 * time.Millisecond
|
||||
|
||||
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
|
||||
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
|
||||
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
|
||||
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
|
||||
// keeping DNS resolution from contending with network-map processing.
|
||||
type dnsPeerActivator struct {
|
||||
connMgr *ConnMgr
|
||||
peerStore *peerstore.Store
|
||||
status *peer.Status
|
||||
// ctx is the engine's long-lived context. The connection dial is tied to it
|
||||
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
|
||||
// still completes in the background rather than being cancelled at the deadline.
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
|
||||
// until one is connected or ctx (the per-query DNS wait budget) expires.
|
||||
// Activation itself is tied to the engine's long-lived context so the dial
|
||||
// survives a wait that times out. Unknown or already-connected addresses are
|
||||
// skipped, so the steady-state (warm) path adds no latency.
|
||||
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
|
||||
if a == nil || a.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var pending []string
|
||||
for _, addr := range addrs {
|
||||
ip := addr.String()
|
||||
st, ok := a.status.PeerStateByIP(ip)
|
||||
if !ok || st.ConnStatus == peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
conn, ok := a.peerStore.PeerConn(st.PubKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a.connMgr.ActivatePeer(a.ctx, conn)
|
||||
pending = append(pending, ip)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
a.waitConnected(ctx, pending)
|
||||
}
|
||||
|
||||
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
|
||||
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
|
||||
ticker := time.NewTicker(dnsActivationPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for _, ip := range ips {
|
||||
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
LocalKey: "local",
|
||||
WgConfig: peer.WgConfig{
|
||||
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
|
||||
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
|
||||
t.Helper()
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
|
||||
// no-op — these tests exercise the activator's skip/wait logic.
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
|
||||
return &dnsPeerActivator{
|
||||
connMgr: connMgr,
|
||||
peerStore: store,
|
||||
status: status,
|
||||
ctx: context.Background(),
|
||||
}, status, store
|
||||
}
|
||||
|
||||
func TestDNSPeerActivator_NilSafe(t *testing.T) {
|
||||
var a *dnsPeerActivator
|
||||
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
|
||||
// (warm) path adds no latency: already-connected and unknown addresses never
|
||||
// enter the wait loop.
|
||||
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
|
||||
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{
|
||||
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
|
||||
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
|
||||
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
|
||||
})
|
||||
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
|
||||
// returns as soon as a pending peer reports connected, well before the
|
||||
// per-query budget expires.
|
||||
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
|
||||
// never connects releases the DNS response at the per-query budget instead of
|
||||
// blocking it indefinitely.
|
||||
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
|
||||
// with no connection object in the store is not waited on: there is nothing to
|
||||
// activate, so waiting could only ever time out.
|
||||
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
|
||||
a, status, _ := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
|
||||
}
|
||||
@@ -654,16 +654,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||
e.connMgr.Start(e.ctx)
|
||||
|
||||
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||
// exists (it does not at DNS-server construction time). A DNS answer that
|
||||
// points at an idle peer then wakes it before the client's first request.
|
||||
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
|
||||
connMgr: e.connMgr,
|
||||
peerStore: e.peerStore,
|
||||
status: e.statusRecorder,
|
||||
ctx: e.ctx,
|
||||
})
|
||||
|
||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||
e.srWatcher.Start(peer.IsForceRelayed())
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (r *Route) update(ctx context.Context) error {
|
||||
resolved, err := r.resolveDomains(ctx)
|
||||
resolved, err := r.resolveDomains()
|
||||
if err != nil {
|
||||
if len(resolved) == 0 {
|
||||
return fmt.Errorf("resolve domains: %w", err)
|
||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
func (r *Route) resolveDomains() (domainMap, error) {
|
||||
results := make(chan resolveResult)
|
||||
go r.resolve(ctx, results)
|
||||
go r.resolve(results)
|
||||
|
||||
resolved := domainMap{}
|
||||
var merr *multierror.Error
|
||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
func (r *Route) resolve(results chan resolveResult) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, d := range r.route.Domains {
|
||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
go func(domain domain.Domain) {
|
||||
defer wg.Done()
|
||||
|
||||
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||
ips, err := r.getIPsFromResolver(domain)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||
ips, err = lookupHostIPs(ctx, domain)
|
||||
ips, err = net.LookupIP(domain.PunycodeString())
|
||||
if err != nil {
|
||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||
return
|
||||
@@ -364,20 +364,6 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]net.IP, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||
prefixSet := make(map[netip.Prefix]struct{})
|
||||
for _, prefix := range oldPrefixes {
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
return lookupHostIPs(ctx, domain)
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
return net.LookupIP(domain.PunycodeString())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||
@@ -33,7 +32,7 @@ func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
|
||||
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
||||
if err != nil {
|
||||
if queryErr == nil {
|
||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||
|
||||
@@ -233,9 +233,6 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -1081,10 +1081,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
||||
|
||||
if err := s.cleanupConnection(); err != nil {
|
||||
s.mutex.Unlock()
|
||||
if errors.Is(err, ErrServiceNotUp) {
|
||||
log.Debugf("Down called while service not up: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
// todo review to update the status in case any type of error
|
||||
log.Errorf("failed to shut down properly: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1157,7 +1154,7 @@ func (s *Server) cleanupConnection() error {
|
||||
// making the run loop the sole owner of engine shutdown.
|
||||
if engine != nil {
|
||||
if err := engine.Stop(); err != nil {
|
||||
log.Errorf("failed to stop engine during cleanup: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Version } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
@@ -15,26 +12,7 @@ function openUrl(url: string) {
|
||||
|
||||
export const DaemonOutdatedOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, isDaemonOutdated } = useStatus();
|
||||
|
||||
const [guiVersion, setGuiVersion] = useState<string>("-");
|
||||
const clientVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
|
||||
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDaemonOutdated) return;
|
||||
let cancelled = false;
|
||||
Version.GUI()
|
||||
.then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
})
|
||||
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDaemonOutdated]);
|
||||
const { isDaemonOutdated } = useStatus();
|
||||
|
||||
if (!isDaemonOutdated) return null;
|
||||
|
||||
@@ -60,37 +38,10 @@ export const DaemonOutdatedOverlay = () => {
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<p className={"text-sm font-semibold text-nb-gray-100"}>
|
||||
{clientVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.client", { version: clientVersion })
|
||||
)}
|
||||
</p>
|
||||
<p className={"text-sm font-medium text-nb-gray-250"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.gui", { version: guiVersion })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("daemon.outdated.download")}
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,6 @@ type ProfileContextValue = {
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
switchProfileNoConnect: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
@@ -113,16 +112,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// Manage-profiles variant: switches without connecting, so the user can
|
||||
// still adjust the management URL before bringing the connection up.
|
||||
const switchProfileNoConnect = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
@@ -169,7 +158,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -183,7 +171,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProfilesTab() {
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfileNoConnect,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -100,7 +100,7 @@ export function ProfilesTab() {
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
@@ -129,13 +129,14 @@ export function ProfilesTab() {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile before the switch makes it current.
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfileNoConnect(id);
|
||||
await switchProfile(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,13 +73,6 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
let offCancel: (() => void) | undefined;
|
||||
|
||||
// Return the dialog to its interactive state and dismiss the browser popup
|
||||
const resetDialog = () => {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
@@ -112,22 +105,25 @@ export default function SessionExpirationDialog() {
|
||||
if (outcome.kind === "cancel") {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another surface owns this flow; keep the dialog open to retry.
|
||||
if (outcome.result.preempted) {
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
WindowManager.CloseRenewFlow().catch(console.error);
|
||||
|
||||
// Close before the popup so the restore can't flash this window back.
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
resetDialog();
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -143,11 +139,12 @@ export default function SessionExpirationDialog() {
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ type WelcomeStepTrayProps = {
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
|
||||
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
|
||||
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,9 +36,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t(titleKey)}
|
||||
{t("welcome.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Suchen Sie NetBird in der Taskleiste"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Suchen Sie NetBird in der Menüleiste"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Weiter"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Dokumentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client ist veraltet"
|
||||
"message": "NetBird-Dienst ist veraltet"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Neueste Version herunterladen"
|
||||
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
||||
|
||||
@@ -1377,19 +1377,11 @@
|
||||
},
|
||||
"welcome.title": {
|
||||
"message": "Look for NetBird in your tray",
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Look for NetBird in your menu bar",
|
||||
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
"description": "Body of the first onboarding step explaining the tray icon."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continue",
|
||||
@@ -1732,16 +1724,12 @@
|
||||
"description": "Documentation link on the daemon-unavailable overlay."
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
|
||||
"message": "NetBird Service Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
|
||||
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Download Latest",
|
||||
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
|
||||
"message": "Update the NetBird service to use this app.",
|
||||
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Busque NetBird en su bandeja del sistema"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Busque NetBird en su barra de menús"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentación"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client está desactualizado"
|
||||
"message": "El servicio de NetBird está desactualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Descargar la última versión"
|
||||
"message": "Actualice el servicio de NetBird para usar esta aplicación."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Cherchez NetBird dans votre barre d’état système"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cherchez NetBird dans votre barre des menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuer"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Le Client NetBird est obsolète"
|
||||
"message": "Le service NetBird est obsolète"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Télécharger la dernière version"
|
||||
"message": "Mettez à jour le service NetBird pour utiliser cette application."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Keresse a NetBirdöt a tálcán"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Keresse a NetBirdöt a menüsorban"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Folytatás"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Dokumentáció"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "A NetBird Kliens elavult"
|
||||
"message": "A NetBird szolgáltatás elavult"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Legújabb letöltése"
|
||||
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Cerchi NetBird nella tray"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cerchi NetBird nella barra dei menu"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continua"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentazione"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client è obsoleto"
|
||||
"message": "Il servizio NetBird è obsoleto"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Scarica l'ultima versione"
|
||||
"message": "Aggiorna il servizio NetBird per usare questa app."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "トレイの NetBird を確認してください"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "メニューバーの NetBird を確認してください"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "続ける"
|
||||
},
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Procure o NetBird na sua bandeja"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Procure o NetBird na sua barra de menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentação"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "O NetBird Client está desatualizado"
|
||||
"message": "O serviço NetBird está desatualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Baixar a versão mais recente"
|
||||
"message": "Atualize o serviço NetBird para usar este aplicativo."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Найдите NetBird в системном трее"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Найдите NetBird в строке меню"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Продолжить"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Документация"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Клиент NetBird устарел"
|
||||
"message": "Служба NetBird устарела"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Скачать последнюю версию"
|
||||
"message": "Обновите службу NetBird, чтобы использовать это приложение."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "在托盘中查找 NetBird"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "在菜单栏中查找 NetBird"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "继续"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "文档"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird 客户端版本过旧"
|
||||
"message": "NetBird 服务版本过旧"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。"
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "下载最新版本"
|
||||
"message": "请更新 NetBird 服务以使用此应用。"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"
|
||||
|
||||
@@ -12,15 +12,13 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ProfileSwitcher holds the switch policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. SwitchActive (plain selection:
|
||||
// header dropdown, tray submenu) always connects after the switch;
|
||||
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
|
||||
// still adjust the management URL before connecting. prevStatus from
|
||||
// DaemonFeed.Get at entry only decides the teardown:
|
||||
// ProfileSwitcher holds the reconnect policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. The policy keys off prevStatus
|
||||
// from DaemonFeed.Get at SwitchActive entry:
|
||||
//
|
||||
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
|
||||
// Idle → no Down.
|
||||
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
|
||||
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
|
||||
// Idle → Switch only.
|
||||
type ProfileSwitcher struct {
|
||||
profiles *Profiles
|
||||
connection *Connection
|
||||
@@ -31,40 +29,29 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
|
||||
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
||||
}
|
||||
|
||||
// SwitchActive switches to the named profile and always connects afterwards.
|
||||
// SwitchActive switches to the named profile applying the reconnect policy.
|
||||
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, true)
|
||||
}
|
||||
|
||||
// SwitchActiveNoConnect switches to the named profile without connecting,
|
||||
// tearing down any existing connection first.
|
||||
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, false)
|
||||
}
|
||||
|
||||
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
|
||||
prevStatus := ""
|
||||
if s.feed != nil {
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
|
||||
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting) ||
|
||||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting)
|
||||
needsDown := wasActive ||
|
||||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
||||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
||||
strings.EqualFold(prevStatus, StatusSessionExpired)
|
||||
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, connect, needsDown)
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, wasActive, needsDown)
|
||||
|
||||
// Optimistic Connecting paint plus stale-push suppression during Down (see
|
||||
// DaemonFeed suppression table); also arms the login-watch that pops
|
||||
// browser-login when the new profile turns out to need SSO.
|
||||
if connect && s.feed != nil {
|
||||
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
|
||||
// stale Connected + transient Idle pushes during Down that must be
|
||||
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
|
||||
if wasActive {
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
@@ -89,9 +76,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
}
|
||||
}
|
||||
|
||||
if connect {
|
||||
if wasActive {
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,38 +185,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||
}
|
||||
s.hideOtherWindowsLocked("browser-login")
|
||||
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
|
||||
var screen *application.Screen
|
||||
if s.mainWindow != nil {
|
||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||
opts.AlwaysOnTop = false
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
// Open on the active (where users cursor is) display, like the session-expiration dialog.
|
||||
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||
opts.Screen = screen
|
||||
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
||||
bl := s.browserLogin
|
||||
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
|
||||
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
s.mu.Lock()
|
||||
// Only a live user red-X still has this registered; programmatic closers
|
||||
// nil s.browserLogin first and clean up themselves. Guarding here stops a
|
||||
// stale close event from wiping a replacement popup's state.
|
||||
userClosed := s.browserLogin == bl
|
||||
if userClosed {
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
if userClosed {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
}
|
||||
})
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
||||
}
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
s.browserLogin.Show()
|
||||
s.browserLogin.Focus()
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
}
|
||||
|
||||
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
||||
@@ -239,15 +238,6 @@ func (s *WindowManager) CloseBrowserLogin() {
|
||||
s.mu.Lock()
|
||||
w := s.browserLogin
|
||||
s.browserLogin = nil
|
||||
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
||||
// but only if a popup was actually open. The frontend calls this even when no
|
||||
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
||||
// or connection.ts's catch path), and hiddenForLogin is shared with
|
||||
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
||||
// still-running install-progress is hiding.
|
||||
if w != nil {
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
@@ -289,35 +279,6 @@ func (s *WindowManager) CloseSessionExpiration() {
|
||||
}
|
||||
}
|
||||
|
||||
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
|
||||
// closes the browser-login popup and the session-expiration window together.
|
||||
func (s *WindowManager) CloseRenewFlow() {
|
||||
s.mu.Lock()
|
||||
bl := s.browserLogin
|
||||
se := s.sessionExpiration
|
||||
s.browserLogin = nil
|
||||
s.sessionExpiration = nil
|
||||
if se != nil {
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, w := range s.hiddenForLogin {
|
||||
if w != se {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
}
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
|
||||
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||
if bl != nil {
|
||||
bl.Close()
|
||||
}
|
||||
if se != nil {
|
||||
se.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
||||
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
|
||||
@@ -30,8 +30,6 @@ const (
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
quitDownTimeout = 5 * time.Second
|
||||
|
||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
||||
urlDocs = "https://docs.netbird.io"
|
||||
@@ -448,28 +446,11 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
menu.AddSeparator()
|
||||
menu.Add(t.loc.T("tray.menu.quit")).
|
||||
SetAccelerator("CmdOrCtrl+Q").
|
||||
OnClick(func(*application.Context) { t.handleQuit() })
|
||||
OnClick(func(*application.Context) { t.app.Quit() })
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func (t *Tray) handleQuit() {
|
||||
t.profileMu.Lock()
|
||||
if t.switchCancel != nil {
|
||||
t.switchCancel()
|
||||
t.switchCancel = nil
|
||||
}
|
||||
t.profileMu.Unlock()
|
||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
// handleConnect receives the clicked item from the buildMenu closure —
|
||||
// t.upItem is menuMu-guarded and must not be read here.
|
||||
func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
|
||||
@@ -91,14 +91,7 @@ func availableProviders() []providerCase {
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
// A valid Bedrock inference-profile id (region prefix + date + version),
|
||||
// overridable per account. `global.` profiles can be invoked from any
|
||||
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
|
||||
model := os.Getenv("AWS_BEDROCK_MODEL")
|
||||
if model == "" {
|
||||
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
}
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -115,16 +108,8 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||||
Enabled: ptr(true),
|
||||
}
|
||||
if pc.kind != harness.WireVertex {
|
||||
// The router matches the normalized catalog id. Bedrock's request model
|
||||
// travels as a region-prefixed inference-profile id in the URL path
|
||||
// (us.anthropic...), which the router strips before matching, so register
|
||||
// the normalized form here or routing fails as model_not_routable.
|
||||
modelID := pc.model
|
||||
if pc.kind == harness.WireBedrock {
|
||||
modelID = catalogModel(pc)
|
||||
}
|
||||
req.Models = &[]api.AgentNetworkProviderModel{
|
||||
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
}
|
||||
}
|
||||
return req
|
||||
@@ -216,13 +201,11 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
|
||||
for _, pc := range matrix {
|
||||
pc := pc
|
||||
|
||||
@@ -4,7 +4,6 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,29 +15,13 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock
|
||||
// model normalization (region/inference-profile prefix + version suffix) so the
|
||||
// provider is registered under the same catalog key the router matches against.
|
||||
var (
|
||||
bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
||||
bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
||||
)
|
||||
|
||||
// catalogModel returns the normalized catalog id the proxy stamps for a
|
||||
// path-routed provider's configured model — the form the router and guardrail
|
||||
// allowlist compare against (Bedrock region prefix + version stripped, Vertex
|
||||
// @version stripped).
|
||||
// path-routed provider's configured model — the form the guardrail allowlist is
|
||||
// compared against (region prefix / @version stripped).
|
||||
func catalogModel(pc providerCase) string {
|
||||
switch pc.kind {
|
||||
case harness.WireBedrock:
|
||||
m := pc.model
|
||||
for _, p := range bedrockRegionPrefixes {
|
||||
if strings.HasPrefix(m, p) {
|
||||
m = m[len(p):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
return strings.TrimPrefix(pc.model, "us.")
|
||||
case harness.WireVertex:
|
||||
return strings.SplitN(pc.model, "@", 2)[0]
|
||||
default:
|
||||
@@ -164,13 +147,11 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
|
||||
for _, pc := range providers {
|
||||
pc := pc
|
||||
|
||||
@@ -104,13 +104,11 @@ func TestProviderSkipTLSVerification(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
|
||||
// Positive: skip=true reaches the self-signed upstream. Retry to absorb
|
||||
// tunnel/DNS jitter on the first call; success also proves the path works.
|
||||
|
||||
@@ -106,13 +106,11 @@ func TestVLLMProvider(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
|
||||
before, _ := srv.ListAccessLogs(ctx)
|
||||
sessionID := "e2e-session-vllm"
|
||||
|
||||
3
go.mod
3
go.mod
@@ -113,7 +113,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -303,6 +303,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wailsapp/wails/webview2 v1.0.27 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -660,8 +660,10 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60=
|
||||
github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns=
|
||||
github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
||||
@@ -3,9 +3,10 @@ package labelgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
// pickAttempts caps the random retries before falling back to the
|
||||
@@ -40,16 +41,15 @@ func uniqueWords() []string {
|
||||
// PickUnique selects a label not already in `taken`. It tries up to
|
||||
// pickAttempts random picks; on exhaustion it scans the deduplicated
|
||||
// wordlist for any remaining free entry, and if none is left appends
|
||||
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
|
||||
// is responsible for seeding rng (math/rand).
|
||||
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
|
||||
// `-<fallbackSuffix>` to a random word and returns.
|
||||
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
|
||||
pool := uniqueWords()
|
||||
if len(pool) == 0 {
|
||||
return fallbackSuffix
|
||||
}
|
||||
|
||||
for i := 0; i < pickAttempts; i++ {
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
@@ -61,6 +61,6 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
|
||||
}
|
||||
}
|
||||
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -9,19 +9,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPickUnique_DeterministicWithSeededRng locks the property the
|
||||
// caller relies on: same seed + same taken set → same pick. Without
|
||||
// that, the bootstrap flow can't reproduce a label across retries.
|
||||
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
|
||||
taken := map[string]struct{}{}
|
||||
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
|
||||
// taken set is always drawn verbatim from the wordlist.
|
||||
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
|
||||
got := PickUnique(map[string]struct{}{}, "abcd")
|
||||
|
||||
rngA := rand.New(rand.NewSource(42))
|
||||
rngB := rand.New(rand.NewSource(42))
|
||||
|
||||
a := PickUnique(rngA, taken, "abcd")
|
||||
b := PickUnique(rngB, taken, "abcd")
|
||||
|
||||
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
|
||||
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
|
||||
}
|
||||
|
||||
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
|
||||
@@ -46,8 +39,7 @@ func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
_, isFree := free[got]
|
||||
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
|
||||
@@ -65,8 +57,7 @@ func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -123,11 +122,6 @@ type managerImpl struct {
|
||||
// accountID, then by synthesised service ID.
|
||||
reconcileMu sync.Mutex
|
||||
reconcileCache map[string]map[string]*proto.ProxyMapping
|
||||
|
||||
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
|
||||
// state; concurrent provider creates would otherwise race.
|
||||
labelRngMu sync.Mutex
|
||||
labelRng *rand.Rand
|
||||
}
|
||||
|
||||
// NewManager constructs the persistent Agent Network manager. The
|
||||
@@ -147,7 +141,6 @@ func NewManager(
|
||||
permissionsManager: permissionsManager,
|
||||
proxyController: proxyController,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,9 +646,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
||||
suffix = suffix[:4]
|
||||
}
|
||||
|
||||
m.labelRngMu.Lock()
|
||||
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
|
||||
m.labelRngMu.Unlock()
|
||||
subdomain := labelgen.PickUnique(taken, suffix)
|
||||
|
||||
now := time.Now().UTC()
|
||||
settings := &types.Settings{
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -63,7 +62,7 @@ const (
|
||||
type userLoggedInOnce bool
|
||||
|
||||
func cacheEntryExpiration() time.Duration {
|
||||
r := rand.Intn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
r := util.RandIntn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
return time.Duration(r) * time.Millisecond
|
||||
}
|
||||
|
||||
@@ -2455,8 +2454,7 @@ func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transacti
|
||||
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
|
||||
}
|
||||
if network.NetV6.IP == nil {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
network.NetV6 = types.AllocateIPv6Subnet(r)
|
||||
network.NetV6 = types.AllocateIPv6Subnet()
|
||||
|
||||
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
|
||||
ones, _ := network.NetV6.Mask.Size()
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +27,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Allocate IPv6 subnet for the account
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet()
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account))
|
||||
|
||||
// Create setup key
|
||||
|
||||
@@ -2,11 +2,12 @@ package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,31 +34,32 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int)
|
||||
|
||||
//Set special character
|
||||
for i := 0; i < minSpecialChar; i++ {
|
||||
random := rand.Intn(len(specialCharSet))
|
||||
random := util.RandIntn(len(specialCharSet))
|
||||
password.WriteString(string(specialCharSet[random]))
|
||||
}
|
||||
|
||||
//Set numeric
|
||||
for i := 0; i < minNum; i++ {
|
||||
random := rand.Intn(len(numberSet))
|
||||
random := util.RandIntn(len(numberSet))
|
||||
password.WriteString(string(numberSet[random]))
|
||||
}
|
||||
|
||||
//Set uppercase
|
||||
for i := 0; i < minUpperCase; i++ {
|
||||
random := rand.Intn(len(upperCharSet))
|
||||
random := util.RandIntn(len(upperCharSet))
|
||||
password.WriteString(string(upperCharSet[random]))
|
||||
}
|
||||
|
||||
remainingLength := passwordLength - minSpecialChar - minNum - minUpperCase
|
||||
for i := 0; i < remainingLength; i++ {
|
||||
random := rand.Intn(len(allCharSet))
|
||||
random := util.RandIntn(len(allCharSet))
|
||||
password.WriteString(string(allCharSet[random]))
|
||||
}
|
||||
inRune := []rune(password.String())
|
||||
rand.Shuffle(len(inRune), func(i, j int) {
|
||||
for i := len(inRune) - 1; i > 0; i-- {
|
||||
j := util.RandIntn(i + 1)
|
||||
inRune[i], inRune[j] = inRune[j], inRune[i]
|
||||
})
|
||||
}
|
||||
return string(inRune)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/c-robinson/iplib"
|
||||
"github.com/rs/xid"
|
||||
@@ -137,14 +136,12 @@ func NewNetwork() *Network {
|
||||
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
|
||||
sub, _ := n.Subnet(SubnetSize)
|
||||
|
||||
s := rand.NewSource(time.Now().UnixNano())
|
||||
r := rand.New(s)
|
||||
intn := r.Intn(len(sub))
|
||||
intn := util.RandIntn(len(sub))
|
||||
|
||||
return &Network{
|
||||
Identifier: xid.New().String(),
|
||||
Net: sub[intn].IPNet,
|
||||
NetV6: AllocateIPv6Subnet(r),
|
||||
NetV6: AllocateIPv6Subnet(),
|
||||
Dns: "",
|
||||
Serial: 0,
|
||||
}
|
||||
@@ -154,18 +151,13 @@ func NewNetwork() *Network {
|
||||
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
|
||||
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
|
||||
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
|
||||
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
|
||||
func AllocateIPv6Subnet() net.IPNet {
|
||||
ip := make(net.IP, 16)
|
||||
ip[0] = 0xfd
|
||||
// Bytes 1-5: 40-bit random Global ID
|
||||
ip[1] = byte(r.Intn(256))
|
||||
ip[2] = byte(r.Intn(256))
|
||||
ip[3] = byte(r.Intn(256))
|
||||
ip[4] = byte(r.Intn(256))
|
||||
ip[5] = byte(r.Intn(256))
|
||||
// Bytes 6-7: 16-bit random Subnet ID
|
||||
ip[6] = byte(r.Intn(256))
|
||||
ip[7] = byte(r.Intn(256))
|
||||
// Bytes 1-5: 40-bit random Global ID, bytes 6-7: 16-bit random Subnet ID
|
||||
if _, err := rand.Read(ip[1:8]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
@@ -217,11 +209,10 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
|
||||
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
maxAttempts := (int(totalIPs) - len(taken)) / 100
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
|
||||
candidate := baseIP + offset
|
||||
if _, exists := taken[candidate]; !exists {
|
||||
return uint32ToIP(candidate), nil
|
||||
@@ -245,8 +236,7 @@ func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
|
||||
hostBits := 32 - prefix.Bits()
|
||||
totalIPs := uint32(1 << hostBits)
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
|
||||
|
||||
candidate := baseIP + offset
|
||||
return uint32ToIP(candidate), nil
|
||||
@@ -262,23 +252,26 @@ func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
|
||||
|
||||
ip := prefix.Addr().As16()
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// Determine which byte the host bits start in
|
||||
firstHostByte := ones / 8
|
||||
// If the prefix doesn't end on a byte boundary, handle the partial byte
|
||||
partialBits := ones % 8
|
||||
|
||||
var rnd [16]byte
|
||||
if _, err := rand.Read(rnd[firstHostByte:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
|
||||
if partialBits > 0 {
|
||||
// Keep the network bits in the partial byte, randomize the rest
|
||||
hostMask := byte(0xff >> partialBits)
|
||||
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
|
||||
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (rnd[firstHostByte] & hostMask)
|
||||
firstHostByte++
|
||||
}
|
||||
|
||||
// Randomize remaining full host bytes
|
||||
for i := firstHostByte; i < 16; i++ {
|
||||
ip[i] = byte(rng.Intn(256))
|
||||
ip[i] = rnd[i]
|
||||
}
|
||||
|
||||
// Avoid all-zeros and all-ones host parts by checking only host bits.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// RandIntn returns a uniformly distributed int in [0, n) sourced from
|
||||
// crypto/rand. It panics if n <= 0 or the platform randomness source fails.
|
||||
func RandIntn(n int) int {
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return int(v.Int64())
|
||||
}
|
||||
|
||||
// Difference returns the elements in `a` that aren't in `b`.
|
||||
func Difference(a, b []string) []string {
|
||||
mb := make(map[string]struct{}, len(b))
|
||||
|
||||
@@ -14,7 +14,6 @@ COPY proxy ./proxy
|
||||
COPY route ./route
|
||||
COPY shared ./shared
|
||||
COPY sharedsock ./sharedsock
|
||||
COPY trustedproxy ./trustedproxy
|
||||
COPY upload-server ./upload-server
|
||||
COPY util ./util
|
||||
COPY version ./version
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/proxy"
|
||||
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -210,7 +209,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
|
||||
}
|
||||
|
||||
parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies)
|
||||
parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -67,7 +66,7 @@ type denyBucket struct {
|
||||
type Logger struct {
|
||||
client gRPCClient
|
||||
logger *log.Logger
|
||||
trustedProxies *trustedproxy.List
|
||||
trustedProxies []netip.Prefix
|
||||
|
||||
usageMux sync.Mutex
|
||||
domainUsage map[string]*domainUsage
|
||||
@@ -83,7 +82,7 @@ type Logger struct {
|
||||
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
||||
// configures which upstream proxy IP ranges are trusted for extracting
|
||||
// the real client IP from X-Forwarded-For headers.
|
||||
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger {
|
||||
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// extractSourceIP resolves the real client IP from the request using trusted
|
||||
// proxy configuration. When trustedProxies is non-empty and the direct
|
||||
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
||||
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
||||
func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr {
|
||||
return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr {
|
||||
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
|
||||
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
|
||||
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
||||
|
||||
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
|
||||
// version/throughput suffix of a Bedrock model id.
|
||||
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
||||
|
||||
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
|
||||
// prefix, and the version/throughput suffix from a Bedrock model id so it
|
||||
// matches the catalog/pricing key, e.g.
|
||||
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
|
||||
// and the inference-profile ARN's last segment likewise. It is the single
|
||||
// source of truth shared by the request parser (which normalizes the request
|
||||
// model from the URL path) and the router (which normalizes the operator's
|
||||
// registered Bedrock model ids so both sides compare equal).
|
||||
func NormalizeBedrockModel(modelID string) string {
|
||||
m := modelID
|
||||
if strings.HasPrefix(m, "arn:") {
|
||||
if i := strings.LastIndex(m, "/"); i >= 0 {
|
||||
m = m[i+1:]
|
||||
}
|
||||
}
|
||||
for _, p := range bedrockRegionPrefixes {
|
||||
if strings.HasPrefix(m, p) {
|
||||
m = m[len(p):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeBedrockModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
"us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
|
||||
"us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
|
||||
"amazon.nova-pro-v1:0": "amazon.nova-pro",
|
||||
// Inference-profile ARN — model id lives in the last path segment.
|
||||
"arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package llm_router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
|
||||
// Bedrock routing gap: the request model reaches the router already normalized
|
||||
// (the parser strips the region/inference-profile prefix and version suffix),
|
||||
// so a provider registered with the raw inference-profile id must still match.
|
||||
func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
||||
route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"),
|
||||
"raw region-prefixed Bedrock model must match the normalized request model")
|
||||
assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"),
|
||||
"a model outside the provider's list must not match")
|
||||
|
||||
// A provider registered with the already-normalized id also matches.
|
||||
normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"),
|
||||
"normalized Bedrock model must match")
|
||||
|
||||
// Non-Bedrock routes keep exact matching (no prefix stripping).
|
||||
openai := ProviderRoute{Models: []string{"gpt-4o"}}
|
||||
assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match")
|
||||
assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
|
||||
"non-Bedrock routes must not strip a us. prefix")
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -556,14 +555,6 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
||||
if candidate == model {
|
||||
return true
|
||||
}
|
||||
// Bedrock request models reach the router already normalized (the parser
|
||||
// strips the region / inference-profile prefix and version suffix), but
|
||||
// the operator may register the raw inference-profile id (e.g.
|
||||
// "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides
|
||||
// compare equal; otherwise a native Bedrock request denies as not-routable.
|
||||
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/proxy/web"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
type ReverseProxy struct {
|
||||
@@ -30,10 +29,10 @@ type ReverseProxy struct {
|
||||
// forwardedProto overrides the X-Forwarded-Proto header value.
|
||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||
forwardedProto string
|
||||
// trustedProxies is the set of trusted upstream proxies. When the direct
|
||||
// connection comes from a trusted proxy, forwarding headers are preserved
|
||||
// and appended to instead of being stripped.
|
||||
trustedProxies *trustedproxy.List
|
||||
// trustedProxies is a list of IP prefixes for trusted upstream proxies.
|
||||
// When the direct connection comes from a trusted proxy, forwarding
|
||||
// headers are preserved and appended to instead of being stripped.
|
||||
trustedProxies []netip.Prefix
|
||||
mappingsMux sync.RWMutex
|
||||
mappings map[string]Mapping
|
||||
logger *log.Logger
|
||||
@@ -64,7 +63,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option {
|
||||
// between requested URLs and targets.
|
||||
// The internal mappings can be modified using the AddMapping
|
||||
// and RemoveMapping functions.
|
||||
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy {
|
||||
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
@@ -528,7 +527,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
|
||||
if !types.IsOverlayOrigin(r.Context()) {
|
||||
return false
|
||||
}
|
||||
srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr)
|
||||
srcIP := extractHostIP(r.RemoteAddr)
|
||||
if !srcIP.IsValid() {
|
||||
return false
|
||||
}
|
||||
@@ -579,9 +578,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
|
||||
|
||||
stampNetBirdIdentity(r)
|
||||
|
||||
clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr)
|
||||
clientIP := extractHostIP(r.In.RemoteAddr)
|
||||
|
||||
if p.trustedProxies.Contains(clientIP) {
|
||||
if isTrustedAddr(clientIP, p.trustedProxies) {
|
||||
p.setTrustedForwardingHeaders(r, clientIP)
|
||||
} else {
|
||||
p.setUntrustedForwardingHeaders(r, clientIP)
|
||||
@@ -665,7 +664,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli
|
||||
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
|
||||
r.Out.Header.Set("X-Real-IP", realIP)
|
||||
} else {
|
||||
resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"))
|
||||
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
||||
r.Out.Header.Set("X-Real-IP", resolved.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/proxy/web"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
||||
@@ -303,7 +302,7 @@ func TestExtractHostIP(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr))
|
||||
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -331,7 +330,7 @@ func TestExtractForwardedPort(t *testing.T) {
|
||||
|
||||
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
||||
target, _ := url.Parse("http://backend.internal:8080")
|
||||
trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")})
|
||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
|
||||
t.Run("appends to X-Forwarded-For", func(t *testing.T) {
|
||||
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
||||
|
||||
81
proxy/internal/proxy/trustedproxy.go
Normal file
81
proxy/internal/proxy/trustedproxy.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes.
|
||||
func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
|
||||
addr, err := netip.ParseAddr(ipStr)
|
||||
if err != nil || len(trusted) == 0 {
|
||||
return false
|
||||
}
|
||||
return isTrustedAddr(addr.Unmap(), trusted)
|
||||
}
|
||||
|
||||
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list.
|
||||
// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes.
|
||||
// The first untrusted IP is the real client.
|
||||
//
|
||||
// If the trusted list is empty or remoteAddr is not trusted, it returns the
|
||||
// remoteAddr IP directly (ignoring any forwarding headers).
|
||||
func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr {
|
||||
remoteIP := extractHostIP(remoteAddr)
|
||||
|
||||
if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) {
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
if xff == "" {
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
parts := strings.Split(xff, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(parts[i])
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !isTrustedAddr(addr, trusted) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
// All IPs in XFF are trusted; return the leftmost as best guess.
|
||||
if first := strings.TrimSpace(parts[0]); first != "" {
|
||||
if addr, err := netip.ParseAddr(first); err == nil {
|
||||
return addr.Unmap()
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
// extractHostIP parses the IP from a host:port string and returns it unmapped.
|
||||
func extractHostIP(hostPort string) netip.Addr {
|
||||
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
||||
return ap.Addr().Unmap()
|
||||
}
|
||||
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
||||
return addr.Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
// isTrustedAddr checks if the given address falls within any of the trusted prefixes.
|
||||
func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
129
proxy/internal/proxy/trustedproxy_test.go
Normal file
129
proxy/internal/proxy/trustedproxy_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsTrustedProxy(t *testing.T) {
|
||||
trusted := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
trusted []netip.Prefix
|
||||
want bool
|
||||
}{
|
||||
{"empty trusted list", "10.0.0.1", nil, false},
|
||||
{"IP within /8 prefix", "10.1.2.3", trusted, true},
|
||||
{"IP within /24 prefix", "192.168.1.100", trusted, true},
|
||||
{"IP outside all prefixes", "203.0.113.50", trusted, false},
|
||||
{"boundary IP just outside prefix", "192.168.2.1", trusted, false},
|
||||
{"unparsable IP", "not-an-ip", trusted, false},
|
||||
{"IPv6 in trusted range", "fd00::1", trusted, true},
|
||||
{"IPv6 outside range", "2001:db8::1", trusted, false},
|
||||
{"empty string", "", trusted, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP(t *testing.T) {
|
||||
trusted := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
trusted []netip.Prefix
|
||||
want netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "empty trusted list returns RemoteAddr",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4",
|
||||
trusted: nil,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "untrusted RemoteAddr ignores XFF",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4, 10.0.0.1",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with single client in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.1"),
|
||||
},
|
||||
{
|
||||
name: "all XFF IPs trusted returns leftmost",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.2"),
|
||||
},
|
||||
{
|
||||
name: "XFF with whitespace",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "XFF with empty segments",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50,,10.0.0.2",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "multi-hop with mixed trust",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr without port",
|
||||
remoteAddr: "10.0.0.1",
|
||||
xff: "203.0.113.50",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/proxy/internal/acme"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
// Config bundles every knob the proxy reads at construction time. It mirrors
|
||||
@@ -83,9 +83,9 @@ type Config struct {
|
||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to
|
||||
// backends. Valid values: "auto", "http", "https".
|
||||
ForwardedProto string
|
||||
// TrustedProxies is the set of trusted upstream proxies that may set
|
||||
// forwarding headers.
|
||||
TrustedProxies *trustedproxy.List
|
||||
// TrustedProxies is a list of IP prefixes for trusted upstream
|
||||
// proxies that may set forwarding headers.
|
||||
TrustedProxies []netip.Prefix
|
||||
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
||||
// Zero asks the OS for a random port.
|
||||
WireguardPort uint16
|
||||
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
||||
ProxyProtocol: true,
|
||||
}
|
||||
|
||||
@@ -68,7 +66,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
@@ -82,7 +80,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
@@ -96,7 +94,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
|
||||
@@ -67,7 +67,6 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/web"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/util/embeddedroots"
|
||||
)
|
||||
|
||||
@@ -80,19 +79,19 @@ type portRouter struct {
|
||||
|
||||
type Server struct {
|
||||
ctx context.Context
|
||||
mgmtClient proto.ProxyServiceClient
|
||||
proxy *proxy.ReverseProxy
|
||||
netbird *roundtrip.NetBird
|
||||
acme *acme.Manager
|
||||
mgmtClient proto.ProxyServiceClient
|
||||
proxy *proxy.ReverseProxy
|
||||
netbird *roundtrip.NetBird
|
||||
acme *acme.Manager
|
||||
staticCertWatcher *certwatch.Watcher
|
||||
auth *auth.Middleware
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
debug *http.Server
|
||||
healthServer *health.Server
|
||||
healthChecker *health.Checker
|
||||
meter *proxymetrics.Metrics
|
||||
accessLog *accesslog.Logger
|
||||
auth *auth.Middleware
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
debug *http.Server
|
||||
healthServer *health.Server
|
||||
healthChecker *health.Checker
|
||||
meter *proxymetrics.Metrics
|
||||
accessLog *accesslog.Logger
|
||||
// middlewareManager drives per-target middleware dispatch. Always
|
||||
// constructed during boot; an empty registry produces empty chains and
|
||||
// the reverse-proxy stays on the no-capture fast path.
|
||||
@@ -100,16 +99,16 @@ type Server struct {
|
||||
// middlewareRegistry is the source of registered middleware factories.
|
||||
// Concrete middlewares register themselves through init().
|
||||
middlewareRegistry *middleware.Registry
|
||||
mainRouter *nbtcp.Router
|
||||
mainPort uint16
|
||||
udpMu sync.Mutex
|
||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||
udpRelayWg sync.WaitGroup
|
||||
portMu sync.RWMutex
|
||||
portRouters map[uint16]*portRouter
|
||||
svcPorts map[types.ServiceID][]uint16
|
||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||
portRouterWg sync.WaitGroup
|
||||
mainRouter *nbtcp.Router
|
||||
mainPort uint16
|
||||
udpMu sync.Mutex
|
||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||
udpRelayWg sync.WaitGroup
|
||||
portMu sync.RWMutex
|
||||
portRouters map[uint16]*portRouter
|
||||
svcPorts map[types.ServiceID][]uint16
|
||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||
portRouterWg sync.WaitGroup
|
||||
|
||||
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
||||
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
||||
@@ -193,10 +192,10 @@ type Server struct {
|
||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
|
||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||
ForwardedProto string
|
||||
// TrustedProxies is the set of trusted upstream proxies. When set,
|
||||
// forwarding headers from these sources are preserved and appended to
|
||||
// instead of being stripped.
|
||||
TrustedProxies *trustedproxy.List
|
||||
// TrustedProxies is a list of IP prefixes for trusted upstream proxies.
|
||||
// When set, forwarding headers from these sources are preserved and
|
||||
// appended to instead of being stripped.
|
||||
TrustedProxies []netip.Prefix
|
||||
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
||||
// for a random OS-assigned port. A fixed port only works with
|
||||
// single-account deployments; multiple accounts will fail to bind
|
||||
@@ -719,7 +718,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener {
|
||||
Listener: ln,
|
||||
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
||||
}
|
||||
if !s.TrustedProxies.Empty() {
|
||||
if len(s.TrustedProxies) > 0 {
|
||||
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
||||
} else {
|
||||
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
|
||||
@@ -743,8 +742,10 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
|
||||
addr = addr.Unmap()
|
||||
|
||||
// called per accept
|
||||
if s.TrustedProxies.Contains(addr) {
|
||||
return proxyproto.REQUIRE, nil
|
||||
for _, prefix := range s.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
return proxyproto.REQUIRE, nil
|
||||
}
|
||||
}
|
||||
return proxyproto.IGNORE, nil
|
||||
}
|
||||
|
||||
43
proxy/trustedproxy.go
Normal file
43
proxy/trustedproxy.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs
|
||||
// into a slice of netip.Prefix values suitable for trusted proxy configuration.
|
||||
// Bare IPs are converted to single-host prefixes (/32 or /128).
|
||||
func ParseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := netip.ParsePrefix(part)
|
||||
if err == nil {
|
||||
prefixes = append(prefixes, prefix)
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := netip.ParseAddr(part)
|
||||
if addrErr != nil {
|
||||
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
||||
}
|
||||
|
||||
bits := 32
|
||||
if addr.Is6() {
|
||||
bits = 128
|
||||
}
|
||||
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
||||
}
|
||||
return prefixes, nil
|
||||
}
|
||||
90
proxy/trustedproxy_test.go
Normal file
90
proxy/trustedproxy_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []netip.Prefix
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string returns nil",
|
||||
raw: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single CIDR",
|
||||
raw: "10.0.0.0/8",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv4",
|
||||
raw: "1.2.3.4",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv6",
|
||||
raw: "::1",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
},
|
||||
{
|
||||
name: "comma-separated CIDRs",
|
||||
raw: "10.0.0.0/8, 192.168.1.0/24",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed CIDRs and bare IPs",
|
||||
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("1.2.3.4/32"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace around entries",
|
||||
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailing comma produces no extra entry",
|
||||
raw: "10.0.0.0/8,",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "invalid entry",
|
||||
raw: "not-an-ip",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "partially invalid",
|
||||
raw: "10.0.0.0/8, garbage",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseTrustedProxies(tt.raw)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/metrics"
|
||||
"github.com/netbirdio/netbird/shared/relay/auth"
|
||||
"github.com/netbirdio/netbird/stun"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -46,9 +45,6 @@ type Config struct {
|
||||
LogLevel string
|
||||
LogFile string
|
||||
HealthcheckListenAddress string
|
||||
// TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose
|
||||
// X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers.
|
||||
TrustedProxies string
|
||||
// STUN server configuration
|
||||
EnableSTUN bool
|
||||
STUNPorts []int
|
||||
@@ -120,7 +116,6 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
||||
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address")
|
||||
rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
|
||||
rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
|
||||
@@ -160,15 +155,8 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("setup metrics: %v", err)
|
||||
}
|
||||
|
||||
trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies)
|
||||
if err != nil {
|
||||
log.Debugf("failed to parse trusted proxies: %s", err)
|
||||
return fmt.Errorf("failed to parse trusted proxies: %s", err)
|
||||
}
|
||||
|
||||
srvListenerCfg := server.ListenerConfig{
|
||||
Address: cobraConfig.ListenAddress,
|
||||
TrustedProxies: trustedProxies,
|
||||
Address: cobraConfig.ListenAddress,
|
||||
}
|
||||
|
||||
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/netbirdio/netbird/relay/protocol"
|
||||
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
|
||||
"github.com/netbirdio/netbird/shared/relay"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,9 +27,6 @@ type Listener struct {
|
||||
Address string
|
||||
// TLSConfig is the TLS configuration for the server.
|
||||
TLSConfig *tls.Config
|
||||
// TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port
|
||||
// headers are trusted. Headers from any other immediate peer are ignored.
|
||||
TrustedProxies *trustedproxy.List
|
||||
|
||||
server *http.Server
|
||||
acceptFn func(conn relaylistener.Conn)
|
||||
@@ -79,7 +75,7 @@ func (l *Listener) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
||||
connRemoteAddr := remoteAddr(r, l.TrustedProxies)
|
||||
connRemoteAddr := remoteAddr(r)
|
||||
|
||||
acceptOptions := &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
@@ -106,17 +102,9 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
||||
l.acceptFn(conn)
|
||||
}
|
||||
|
||||
func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string {
|
||||
realIP := r.Header.Get("X-Real-Ip")
|
||||
realPort := r.Header.Get("X-Real-Port")
|
||||
if realIP == "" || realPort == "" {
|
||||
func remoteAddr(r *http.Request) string {
|
||||
if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
if !trustedProxies.IsTrusted(r.RemoteAddr) {
|
||||
log.Debugf("ignoring X-Real-Ip header from untrusted peer %s", r.RemoteAddr)
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return net.JoinHostPort(realIP, realPort)
|
||||
return net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port"))
|
||||
}
|
||||
|
||||
@@ -15,17 +15,14 @@ import (
|
||||
"github.com/netbirdio/netbird/relay/server/listener/quic"
|
||||
"github.com/netbirdio/netbird/relay/server/listener/ws"
|
||||
quictls "github.com/netbirdio/netbird/shared/relay/tls"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
// ListenerConfig is the configuration for the listener.
|
||||
// Address: the address to bind the listener to. It could be an address behind a reverse proxy.
|
||||
// TLSConfig: the TLS configuration for the listener.
|
||||
// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted.
|
||||
type ListenerConfig struct {
|
||||
Address string
|
||||
TLSConfig *tls.Config
|
||||
TrustedProxies *trustedproxy.List
|
||||
Address string
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// Server is the main entry point for the relay server.
|
||||
@@ -65,9 +62,8 @@ func NewServer(config Config) (*Server, error) {
|
||||
// Listen starts the relay server.
|
||||
func (r *Server) Listen(cfg ListenerConfig) error {
|
||||
wSListener := &ws.Listener{
|
||||
Address: cfg.Address,
|
||||
TLSConfig: cfg.TLSConfig,
|
||||
TrustedProxies: cfg.TrustedProxies,
|
||||
Address: cfg.Address,
|
||||
TLSConfig: cfg.TLSConfig,
|
||||
}
|
||||
|
||||
r.listenerMux.Lock()
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package trustedproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// List holds a parsed set of trusted upstream proxy prefixes and answers trust
|
||||
// questions against it. The zero value (and a nil *List) is a valid, empty list
|
||||
// that never trusts any address, so callers can use it without a nil check.
|
||||
type List struct {
|
||||
prefixes []netip.Prefix
|
||||
}
|
||||
|
||||
// Parse parses a comma-separated list of CIDR prefixes or bare IPs into a List.
|
||||
// Bare IPs are converted to single-host prefixes (/32 or /128). An empty input
|
||||
// yields an empty List that trusts nothing.
|
||||
func Parse(raw string) (*List, error) {
|
||||
if raw == "" {
|
||||
return &List{}, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := netip.ParsePrefix(part)
|
||||
if err == nil {
|
||||
prefixes = append(prefixes, prefix)
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := netip.ParseAddr(part)
|
||||
if addrErr != nil {
|
||||
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
||||
}
|
||||
|
||||
bits := 32
|
||||
if addr.Is6() {
|
||||
bits = 128
|
||||
}
|
||||
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
||||
}
|
||||
return &List{prefixes: prefixes}, nil
|
||||
}
|
||||
|
||||
// FromPrefixes wraps an already-parsed set of prefixes in a List.
|
||||
func FromPrefixes(prefixes []netip.Prefix) *List {
|
||||
return &List{prefixes: prefixes}
|
||||
}
|
||||
|
||||
// Empty reports whether the list contains no prefixes.
|
||||
func (l *List) Empty() bool {
|
||||
return l == nil || len(l.prefixes) == 0
|
||||
}
|
||||
|
||||
// IsTrusted reports whether the given host:port or bare IP falls within the list.
|
||||
func (l *List) IsTrusted(remoteAddr string) bool {
|
||||
if l.Empty() {
|
||||
return false
|
||||
}
|
||||
return l.Contains(ExtractHostIP(remoteAddr))
|
||||
}
|
||||
|
||||
// Contains reports whether the given address falls within any trusted prefix.
|
||||
func (l *List) Contains(addr netip.Addr) bool {
|
||||
if l.Empty() || !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range l.prefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the
|
||||
// list. It walks the XFF chain right-to-left, skipping IPs that match trusted
|
||||
// prefixes; the first untrusted IP is the real client. If the list is empty or
|
||||
// remoteAddr is not trusted, it returns the remoteAddr IP directly, ignoring any
|
||||
// forwarding headers.
|
||||
func (l *List) ResolveClientIP(remoteAddr, xff string) netip.Addr {
|
||||
remoteIP := ExtractHostIP(remoteAddr)
|
||||
|
||||
if l.Empty() || !l.Contains(remoteIP) {
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
if xff == "" {
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
parts := strings.Split(xff, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(parts[i])
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !l.Contains(addr) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
if first := strings.TrimSpace(parts[0]); first != "" {
|
||||
if addr, err := netip.ParseAddr(first); err == nil {
|
||||
return addr.Unmap()
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
// ExtractHostIP parses the IP from a host:port string and returns it unmapped.
|
||||
func ExtractHostIP(hostPort string) netip.Addr {
|
||||
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
||||
return ap.Addr().Unmap()
|
||||
}
|
||||
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
||||
return addr.Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package trustedproxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []netip.Prefix
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string returns empty list",
|
||||
raw: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single CIDR",
|
||||
raw: "10.0.0.0/8",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv4",
|
||||
raw: "1.2.3.4",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv6",
|
||||
raw: "::1",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
},
|
||||
{
|
||||
name: "comma-separated CIDRs",
|
||||
raw: "10.0.0.0/8, 192.168.1.0/24",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed CIDRs and bare IPs",
|
||||
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("1.2.3.4/32"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace around entries",
|
||||
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailing comma produces no extra entry",
|
||||
raw: "10.0.0.0/8,",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "invalid entry",
|
||||
raw: "not-an-ip",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "partially invalid",
|
||||
raw: "10.0.0.0/8, garbage",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.raw)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got.prefixes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIsTrusted(t *testing.T) {
|
||||
list, err := Parse("10.0.0.0/8, 192.168.1.0/24, fd00::/8")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
list *List
|
||||
want bool
|
||||
}{
|
||||
{"nil list", "10.0.0.1", nil, false},
|
||||
{"empty list", "10.0.0.1", &List{}, false},
|
||||
{"IP within /8 prefix", "10.1.2.3", list, true},
|
||||
{"IP within /24 prefix", "192.168.1.100", list, true},
|
||||
{"IP outside all prefixes", "203.0.113.50", list, false},
|
||||
{"boundary IP just outside prefix", "192.168.2.1", list, false},
|
||||
{"unparsable IP", "not-an-ip", list, false},
|
||||
{"IPv6 in trusted range", "fd00::1", list, true},
|
||||
{"IPv6 outside range", "2001:db8::1", list, false},
|
||||
{"empty string", "", list, false},
|
||||
{"host:port within prefix", "10.1.2.3:9999", list, true},
|
||||
{"host:port outside prefix", "203.0.113.50:9999", list, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.list.IsTrusted(tt.addr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResolveClientIP(t *testing.T) {
|
||||
trusted, err := Parse("10.0.0.0/8, 172.16.0.0/12")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
list *List
|
||||
want netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "empty list returns RemoteAddr",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4",
|
||||
list: &List{},
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "nil list returns RemoteAddr",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4",
|
||||
list: nil,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "untrusted RemoteAddr ignores XFF",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4, 10.0.0.1",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with single client in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.1"),
|
||||
},
|
||||
{
|
||||
name: "all XFF IPs trusted returns leftmost",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.2"),
|
||||
},
|
||||
{
|
||||
name: "XFF with whitespace",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "XFF with empty segments",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50,,10.0.0.2",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "multi-hop with mixed trust",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr without port",
|
||||
remoteAddr: "10.0.0.1",
|
||||
xff: "203.0.113.50",
|
||||
list: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.list.ResolveClientIP(tt.remoteAddr, tt.xff))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user