Compare commits

..

1 Commits

Author SHA1 Message Date
Zoltán Papp
ccb271b5bb [client] Make netbird up wait for the daemon to become ready
The CLI up path only tolerated a not-yet-ready daemon via a 10s blocking
dial, so "netbird service start" immediately followed by "netbird up"
(e.g. a container entrypoint) failed with a generic "daemon not running"
error. The container entrypoint worked around this with a shell poll loop
(status --check live) before running up.

Move the readiness wait into the CLI, mirroring how the GUI already dials:

- DialClientGRPCServer now uses grpc.NewClient with a tuned reconnect
  backoff and waits for the connection to reach READY (retrying on
  TRANSIENT_FAILURE) up to a 30s deadline, instead of grpc.DialContext +
  WithBlock with a hard 10s timeout.
- up now polls Status via waitForDaemonStatus, retrying while the RPC is
  Unavailable (socket up but service not yet registered).

Add an explicit daemon-ready signal so clients can wait deterministically
instead of heuristically:

- New optional StatusResponse.daemonReady field (field 5, wire-compatible
  with older GUIs/daemons which leave it unset). Regenerated with the
  pinned protoc v33.1 toolchain so no version churn leaks into the diff.
- The server sets ready once Start succeeds and the DaemonService is
  registered (SetReady, called from the service controller).
- waitForDaemonStatus waits for daemonReady=true (or an already-Connected
  status), with a bounded grace window so older daemons that never set the
  field are not blocked.

Simplify the container entrypoint accordingly: drop the readiness poll
loop (up now waits) and the now-dead NB_ENTRYPOINT_SERVICE_TIMEOUT env,
keeping only the daemon+up process glue and SIGTERM forwarding for clean
shutdown.
2026-07-17 10:30:09 +02:00
124 changed files with 1349 additions and 4543 deletions

View File

@@ -86,7 +86,7 @@ jobs:
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
run: pnpm install --frozen-lockfile
- name: Generate Wails bindings
run: pnpm run bindings

View File

@@ -17,8 +17,7 @@ ENV \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
NB_ENABLE_CAPTURE="false"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG TARGETPLATFORM

View File

@@ -23,8 +23,7 @@ ENV \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
NB_ENABLE_CAPTURE="false"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG TARGETPLATFORM

View File

@@ -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
}

176
client/cmd/dial_test.go Normal file
View File

@@ -0,0 +1,176 @@
package cmd
import (
"context"
"net"
"path/filepath"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// startUnixGRPCServer starts a bare gRPC server listening on a unix socket at path
// and returns a stop function. No services are registered; the connectivity-state
// wait only cares about the transport becoming READY.
func startUnixGRPCServer(t *testing.T, path string) func() {
t.Helper()
lis, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen unix %s: %v", path, err)
}
srv := grpc.NewServer()
go func() { _ = srv.Serve(lis) }()
return srv.Stop
}
func TestDialClientGRPCServer_ConnectsWhenServing(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
stop := startUnixGRPCServer(t, sock)
defer stop()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
if err != nil {
t.Fatalf("expected connection, got error: %v", err)
}
defer conn.Close()
if state := conn.GetState(); state != connectivity.Ready {
t.Fatalf("expected READY, got %s", state)
}
}
// TestDialClientGRPCServer_WaitsForLateServer is the core regression test: the
// daemon socket appears only after the dial has already started, mirroring
// "netbird service start" immediately followed by "netbird up".
func TestDialClientGRPCServer_WaitsForLateServer(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
var stop func()
timer := time.AfterFunc(1*time.Second, func() {
stop = startUnixGRPCServer(t, sock)
})
defer timer.Stop()
defer func() {
if stop != nil {
stop()
}
}()
start := time.Now()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 10*time.Second)
if err != nil {
t.Fatalf("expected connection after late server start, got error: %v", err)
}
defer conn.Close()
if elapsed := time.Since(start); elapsed < 500*time.Millisecond {
t.Fatalf("connected too fast (%s); server should not have been up yet", elapsed)
}
if state := conn.GetState(); state != connectivity.Ready {
t.Fatalf("expected READY, got %s", state)
}
}
// fakeStatusServer serves the Status RPC with a programmable response so we can
// exercise waitForDaemonStatus without spinning up a real engine.
type fakeStatusServer struct {
proto.UnimplementedDaemonServiceServer
resp func() *proto.StatusResponse
}
func (f *fakeStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) {
return f.resp(), nil
}
func startFakeStatusServer(t *testing.T, sock string, resp func() *proto.StatusResponse) func() {
t.Helper()
lis, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen unix %s: %v", sock, err)
}
srv := grpc.NewServer()
proto.RegisterDaemonServiceServer(srv, &fakeStatusServer{resp: resp})
go func() { _ = srv.Serve(lis) }()
return srv.Stop
}
func dialFake(t *testing.T, sock string) proto.DaemonServiceClient {
t.Helper()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
if err != nil {
t.Fatalf("dial fake daemon: %v", err)
}
t.Cleanup(func() { conn.Close() })
return proto.NewDaemonServiceClient(conn)
}
// New daemon that flips DaemonReady=true after a couple of polls: waitForDaemonStatus
// must block until the flag is set, then return.
func TestWaitForDaemonStatus_WaitsForDaemonReady(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
var polls int32
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
n := atomic.AddInt32(&polls, 1)
return &proto.StatusResponse{
Status: string(internal.StatusConnecting),
DaemonReady: n >= 3, // ready only from the 3rd poll on
}
})
defer stop()
client := dialFake(t, sock)
status, err := waitForDaemonStatus(context.Background(), client)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !status.GetDaemonReady() {
t.Fatalf("expected DaemonReady=true, got false")
}
if got := atomic.LoadInt32(&polls); got < 3 {
t.Fatalf("expected at least 3 polls before ready, got %d", got)
}
}
// Older daemon that never sets DaemonReady but reports a healthy (Connected)
// status: waitForDaemonStatus must return promptly via the readiness fallback,
// not block for the whole grace window.
func TestWaitForDaemonStatus_OlderDaemonHealthyStatus(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
return &proto.StatusResponse{Status: string(internal.StatusConnected)} // DaemonReady unset
})
defer stop()
client := dialFake(t, sock)
start := time.Now()
status, err := waitForDaemonStatus(context.Background(), client)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.GetDaemonReady() {
t.Fatalf("expected DaemonReady=false from older daemon")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("returned too slowly (%s); healthy status should short-circuit the grace", elapsed)
}
}
func TestDialClientGRPCServer_TimesOutWhenAbsent(t *testing.T) {
sock := filepath.Join(t.TempDir(), "never.sock")
start := time.Now()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 1*time.Second)
if err == nil {
conn.Close()
t.Fatal("expected timeout error, got nil")
}
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
t.Fatalf("returned too early (%s); should have waited ~timeout", elapsed)
}
}

View File

@@ -20,6 +20,8 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
gbackoff "google.golang.org/grpc/backoff"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
@@ -264,17 +266,70 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
return prefix + upper
}
// DialClientGRPCServer returns client connection to the daemon server.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
// defaultDaemonDialTimeout is how long DialClientGRPCServer waits for the daemon
// to become reachable. It is intentionally generous so that invoking the CLI
// right after "netbird service start" (e.g. from a container entrypoint) tolerates
// the window where the daemon has created its socket but is not yet serving.
const defaultDaemonDialTimeout = 30 * time.Second
return grpc.DialContext(
ctx,
// DialClientGRPCServer returns a client connection to the daemon server. It waits
// for the daemon to become reachable, retrying with backoff until the connection
// reports READY or defaultDaemonDialTimeout elapses. This handles the startup race
// where the daemon socket exists (or is about to) before the gRPC server is serving.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
return dialClientGRPCServer(ctx, addr, defaultDaemonDialTimeout)
}
func dialClientGRPCServer(ctx context.Context, addr string, timeout time.Duration) (*grpc.ClientConn, error) {
conn, err := grpc.NewClient(
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would leave the
// CLI waiting far too long to notice a freshly-started daemon. Mirrors the GUI.
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: gbackoff.Config{
BaseDelay: 1 * time.Second,
Multiplier: 1.6,
Jitter: 0.2,
MaxDelay: 5 * time.Second,
},
}),
)
if err != nil {
return nil, fmt.Errorf("create daemon gRPC client: %w", err)
}
// grpc.NewClient is lazy: it does not connect until the first RPC or until we
// nudge it. Trigger connection attempts and wait until the channel reaches READY.
if err := waitForConnReady(ctx, conn, timeout); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// waitForConnReady drives the gRPC channel out of IDLE and blocks until it becomes
// READY, or until timeout/ctx expires. TRANSIENT_FAILURE (daemon not yet serving)
// is treated as retryable so the caller keeps waiting within the deadline.
func waitForConnReady(ctx context.Context, conn *grpc.ClientConn, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
state := conn.GetState()
switch state {
case connectivity.Ready:
return nil
case connectivity.Idle:
// Kick the lazy channel into connecting.
conn.Connect()
}
if !conn.WaitForStateChange(ctx, state) {
// ctx expired while in `state`.
return fmt.Errorf("timed out after %s waiting for daemon to become ready (last state: %s)", timeout, state)
}
}
}
// WithBackOff execute function in backoff cycle.

View File

@@ -78,6 +78,10 @@ func (p *program) Start(svc service.Service) error {
log.Fatalf("failed to start daemon: %v", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
// The engine is started and the service is registered: from here on the
// daemon serves RPCs backed by a running engine. Report readiness so
// clients (e.g. netbird up) can wait deterministically.
serverInstance.SetReady()
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance

View File

@@ -295,9 +295,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
client := proto.NewDaemonServiceClient(conn)
status, err := client.Status(ctx, &proto.StatusRequest{
WaitForReady: func() *bool { b := true; return &b }(),
})
status, err := waitForDaemonStatus(ctx, client)
if err != nil {
return fmt.Errorf("unable to get daemon status: %v", err)
}
@@ -336,6 +334,79 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
return nil
}
// daemonStatusPollTimeout bounds how long we poll the Status RPC waiting for the
// daemon to answer coherently. The transport is already READY at this point (see
// DialClientGRPCServer), so this only covers the brief window where the gRPC server
// is serving but the daemon engine is still starting up and the Status RPC races
// against server.Start().
const daemonStatusPollTimeout = 15 * time.Second
// daemonReadyGrace bounds how long we keep polling once the daemon answers but
// still reports DaemonReady=false. A freshly-started daemon flips it to true
// within this window; an older daemon that never sets the field simply falls
// through after the grace elapses, preserving backward compatibility.
const daemonReadyGrace = 10 * time.Second
// waitForDaemonStatus fetches the daemon status, waiting for the daemon to become
// ready. It handles two startup races:
//
// 1. The gRPC server is not yet serving: Status fails with Unavailable — retry.
// 2. The server serves but the engine is still starting: a DaemonReady-aware
// daemon reports DaemonReady=false until Start finishes; poll until it flips
// true (bounded by daemonReadyGrace). Older daemons never set DaemonReady, so
// we stop waiting on it after the grace and use the status as-is.
//
// It gives up after daemonStatusPollTimeout.
func waitForDaemonStatus(ctx context.Context, client proto.DaemonServiceClient) (*proto.StatusResponse, error) {
ctx, cancel := context.WithTimeout(ctx, daemonStatusPollTimeout)
defer cancel()
waitForReady := true
req := &proto.StatusRequest{WaitForReady: &waitForReady}
var lastErr error
var firstAnswer time.Time
for {
status, err := client.Status(ctx, req)
if err != nil {
lastErr = err
// Only retry while the daemon is not yet answering; surface real errors.
if s, ok := gstatus.FromError(err); !ok || s.Code() != codes.Unavailable {
return nil, err
}
} else {
// Daemon answered. Explicitly ready (DaemonReady-aware daemon), or
// already fully connected — either way, done. Connected is the only
// status unambiguous enough to short-circuit on: a DaemonReady-aware
// daemon sets the flag at startup, so trusting Connected here can only
// help an older daemon that never sets the flag, without overriding a
// new daemon that legitimately reports DaemonReady=false while starting.
if status.GetDaemonReady() || internal.StatusType(status.GetStatus()) == internal.StatusConnected {
return status, nil
}
// Answered but neither ready-flagged nor connected yet: give a
// DaemonReady-aware daemon a bounded window to finish starting, then
// fall through so an older daemon that never sets the flag isn't
// blocked here.
if firstAnswer.IsZero() {
firstAnswer = time.Now()
} else if time.Since(firstAnswer) >= daemonReadyGrace {
return status, nil
}
lastErr = nil
}
select {
case <-ctx.Done():
if lastErr != nil {
return nil, fmt.Errorf("daemon did not become ready: %w", lastErr)
}
return nil, fmt.Errorf("daemon did not become ready within %s", daemonStatusPollTimeout)
case <-time.After(500 * time.Millisecond):
}
}
}
func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, customDNSAddressConverted []byte, username string) error {
providedSetupKey, err := getSetupKey()

View File

@@ -121,7 +121,6 @@ type Manager struct {
udpTracker *conntrack.UDPTracker
icmpTracker *conntrack.ICMPTracker
tcpTracker *conntrack.TCPTracker
fragments *fragmentTracker
forwarder atomic.Pointer[forwarder.Forwarder]
pendingCapture atomic.Pointer[forwarder.PacketCapture]
logger *nblog.Logger
@@ -184,41 +183,6 @@ func (d *decoder) decodePacket(data []byte) error {
}
}
// decodeTransport decodes the transport header of a first fragment (which
// gopacket leaves undecoded) into the decoder and appends its layer type to
// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
// false if the protocol is unsupported or the header is truncated.
func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
var l4 gopacket.DecodingLayer
var layerType gopacket.LayerType
var minLen int
switch proto {
case layers.IPProtocolTCP:
l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
case layers.IPProtocolUDP:
l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
case layers.IPProtocolICMPv4:
l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
case layers.IPProtocolICMPv6:
l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
default:
return false
}
// Reject a fragment too small to hold the full transport header before
// decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
// the decode avoids gopacket allocating an error on the drop path.
if len(payload) < minLen {
return false
}
if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
return false
}
d.decoded = append(d.decoded, layerType)
return true
}
// Create userspace firewall manager constructor
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
@@ -322,8 +286,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
return nil, fmt.Errorf("update local IPs: %w", err)
}
m.fragments = newFragmentTracker(m.logger)
if disableConntrack {
log.Info("conntrack is disabled")
} else {
@@ -337,7 +299,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
}
}
if err := iface.SetFilter(m); err != nil {
m.fragments.Close()
return nil, fmt.Errorf("set filter: %w", err)
}
return m, nil
@@ -733,10 +694,6 @@ func (m *Manager) resetState() {
m.tcpTracker.Close()
}
if m.fragments != nil {
m.fragments.Close()
}
if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(nil)
fwder.Stop()
@@ -1089,20 +1046,19 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return true
}
// gopacket does not decode the transport header of any IP fragment, so
// fragments take a dedicated path: the first fragment's header is decoded
// and ACL-evaluated here, and the remaining fragments inherit its verdict.
// TODO: pass fragments of routed packets to forwarder
if fragment {
return m.filterInboundFragment(d, srcIP, dstIP, size)
if m.logger.Enabled(nblog.LevelTrace) {
if d.decoded[0] == layers.LayerTypeIPv4 {
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
} else {
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
}
}
return false
}
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
}
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
// decoded (non-fragment) inbound packet. It returns true if the packet should
// be dropped.
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
// TODO: optimize port DNAT by caching matched rules in conntrack
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
// Re-decode after port DNAT translation to update port information
@@ -1133,226 +1089,33 @@ func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, pack
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
}
// fragmentMeta holds the reassembly identity and layout of an IP fragment,
// extracted uniformly for IPv4 and IPv6.
type fragmentMeta struct {
key fragmentKey
// offset is the fragment offset in 8-byte units (zero for the first
// fragment).
offset uint16
// moreFragments is the More Fragments bit. A first fragment with it unset is
// an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
// fragments to inherit a verdict, so it must not be recorded.
moreFragments bool
proto layers.IPProtocol
// l4payload is the fragmentable payload of this fragment. For the first
// fragment it starts with the transport header.
l4payload []byte
// headerEndOctets is the first fragment's payload length in 8-byte units:
// the smallest offset a trailing fragment may start at without overlapping
// the inspected transport header.
headerEndOctets uint16
}
// fragmentMetadata extracts the fragment identity and layout from a decoded IP
// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
// fragment header shorter than 8 bytes), which are then dropped.
func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
switch d.decoded[0] {
case layers.LayerTypeIPv4:
payload := d.ip4.Payload
return fragmentMeta{
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
offset: d.ip4.FragOffset,
moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
proto: d.ip4.Protocol,
l4payload: payload,
headerEndOctets: octets(len(payload)),
}, true
case layers.LayerTypeIPv6:
// IPv6 fragment extension header: 8 bytes, followed by the fragmentable
// payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
payload := d.ip6.Payload
if len(payload) < 8 {
return fragmentMeta{}, false
}
nextHeader := layers.IPProtocol(payload[0])
offsetFlags := binary.BigEndian.Uint16(payload[2:4])
id := binary.BigEndian.Uint32(payload[4:8])
l4 := payload[8:]
return fragmentMeta{
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
offset: offsetFlags >> 3,
moreFragments: offsetFlags&1 != 0,
proto: nextHeader,
l4payload: l4,
headerEndOctets: octets(len(l4)),
}, true
default:
return fragmentMeta{}, false
}
}
// octets rounds a byte length up to whole 8-byte units, the granularity of the
// IP fragment offset field.
func octets(nbytes int) uint16 {
return uint16((nbytes + 7) / 8)
}
// filterInboundFragment decides the fate of an IP fragment. gopacket stops
// decoding at the network layer for every fragment, so the first fragment's
// transport header is decoded and ACL-evaluated here and its verdict recorded;
// the remaining (headerless) fragments inherit that verdict. Anything that
// cannot be tied to an allowed, non-overlapping first fragment is dropped.
func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
meta, ok := fragmentMetadata(d, srcIP, dstIP)
if !ok {
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
}
return true
}
if meta.offset != 0 {
return m.filterTrailingFragment(meta, srcIP, dstIP)
}
// A new first fragment supersedes any recorded verdict for this datagram, so
// a re-sent or overlapping offset-zero fragment can't inherit the old one.
m.fragments.poison(meta.key)
// First fragment: decode its transport header so the ACL can evaluate it. A
// decode failure means the fragment is too small to hold the full transport
// header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
if !d.decodeTransport(meta.proto, meta.l4payload) {
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
srcIP, dstIP, meta.key.id)
}
return true
}
return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
}
// filterTrailingFragment applies a recorded first-fragment verdict to a
// non-first fragment.
func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
switch m.fragments.verdict(meta.key, meta.offset) {
case fragmentAllow:
return false
case fragmentOverlap:
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
srcIP, dstIP, meta.key.id)
}
return true
default:
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
srcIP, dstIP, meta.key.id)
}
return true
}
}
// filterFirstFragment runs the verdict part of the inbound pipeline on a first
// fragment with its transport header decoded. It mirrors filterInboundDecoded
// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
// injection (fragments are left to the stack to reassemble, not forwarded).
// Allowed fragments have their verdict recorded so the datagram's trailing
// fragments inherit it.
func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
m.recordFirstFragment(meta)
return false
}
if m.localipmanager.IsLocalIP(dstIP) {
ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
if blocked {
m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
return true
}
m.trackInbound(d, srcIP, dstIP, ruleID, size)
m.recordFirstFragment(meta)
return false
}
if !m.routingEnabled.Load() {
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
}
return true
}
if m.nativeRouter.Load() {
m.trackInbound(d, srcIP, dstIP, nil, size)
m.recordFirstFragment(meta)
return false
}
// TODO: pass fragments of routed packets to the forwarder; until then
// allowed routed fragments go to the native stack.
srcPort, dstPort := getPortsFromPacket(d)
ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
if !pass {
m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
return true
}
m.recordFirstFragment(meta)
return false
}
// recordFirstFragment caches an allowed first fragment's verdict for its
// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
// complete datagrams with no trailing fragments, so they are not cached and
// cannot exhaust the verdict table.
func (m *Manager) recordFirstFragment(meta fragmentMeta) {
if !meta.moreFragments {
return
}
m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
}
// storeDropFlow logs and records a netflow drop event for an inbound packet
// denied by the ACLs. msg is the trace format taking rule id, protocol, source
// and destination.
func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
pnum := getProtocolFromPacket(d)
srcPort, dstPort := getPortsFromPacket(d)
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
}
m.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: uuid.New(),
Type: nftypes.TypeDrop,
RuleID: ruleID,
Direction: nftypes.Ingress,
Protocol: pnum,
SourceIP: srcIP,
DestIP: dstIP,
SourcePort: srcPort,
DestPort: dstPort,
// TODO: icmp type/code
RxPackets: 1,
RxBytes: uint64(size),
})
}
// handleLocalTraffic handles local traffic.
// If it returns true, the packet should be dropped.
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
if blocked {
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
pnum := getProtocolFromPacket(d)
srcPort, dstPort := getPortsFromPacket(d)
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
}
m.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: uuid.New(),
Type: nftypes.TypeDrop,
RuleID: ruleID,
Direction: nftypes.Ingress,
Protocol: pnum,
SourceIP: srcIP,
DestIP: dstIP,
SourcePort: srcPort,
DestPort: dstPort,
// TODO: icmp type/code
RxPackets: 1,
RxBytes: uint64(size),
})
return true
}
@@ -1405,8 +1168,27 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
if !pass {
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
proto := getProtocolFromPacket(d)
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
}
m.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: uuid.New(),
Type: nftypes.TypeDrop,
RuleID: ruleID,
Direction: nftypes.Ingress,
Protocol: proto,
SourceIP: srcIP,
DestIP: dstIP,
SourcePort: srcPort,
DestPort: dstPort,
// TODO: icmp type/code
RxPackets: 1,
RxBytes: uint64(size),
})
return true
}

View File

@@ -5,9 +5,7 @@ import (
"fmt"
"net"
"net/netip"
"os"
"runtime"
"strconv"
"sync"
"time"
@@ -33,11 +31,6 @@ const (
defaultMaxInFlight = 1024
iosReceiveWindow = 16384
iosMaxInFlight = 256
// envForceTCPRACK overrides the platform default for gVisor's RACK loss
// detection. Set to a truthy value to force RACK on, or a falsy value to
// force it off, on any platform.
envForceTCPRACK = "NB_FORCE_TCP_RACK"
)
type Forwarder struct {
@@ -159,8 +152,6 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
maxInFlight = iosMaxInFlight
}
configureTCPRecovery(s)
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
@@ -475,31 +466,3 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
logger.Debug1("forwarder: raw %s socket access available", network)
return true
}
// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
// it interacts poorly with the host and collapses throughput on routed TCP
// connections (gVisor issue #9778). Other platforms keep the default. The
// EnvForceTCPRACK environment variable overrides the platform default.
func configureTCPRecovery(s *stack.Stack) {
disableRACK := runtime.GOOS == "windows"
if val := os.Getenv(envForceTCPRACK); val != "" {
force, err := strconv.ParseBool(val)
if err != nil {
log.Warnf("parse %s: %v", envForceTCPRACK, err)
} else {
disableRACK = !force
}
}
if !disableRACK {
return
}
opt := tcpip.TCPRecovery(0)
if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
log.Warnf("disable TCP RACK loss detection: %v", err)
return
}
log.Info("forwarder: TCP RACK loss detection disabled")
}

View File

@@ -1,204 +0,0 @@
package uspfilter
import (
"context"
"net/netip"
"os"
"strconv"
"sync"
"time"
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
)
const (
// defaultFragmentTimeout bounds how long a first-fragment verdict is kept
// while the remaining fragments arrive. It mirrors the Linux IP reassembly
// timeout (net.ipv4.ipfrag_time).
defaultFragmentTimeout = 30 * time.Second
// fragmentCleanupInterval is how often expired verdicts are purged.
fragmentCleanupInterval = 10 * time.Second
// defaultMaxFragmentEntries caps the number of concurrently tracked
// fragmented datagrams. The table stays bounded because each datagram is a
// single small entry regardless of how many fragments it is split into, and
// the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
defaultMaxFragmentEntries = 16384
// EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
)
// fragmentVerdict is the decision for a trailing (headerless) fragment.
type fragmentVerdict int
const (
// fragmentDeny drops the fragment: no allowed first fragment is on record.
fragmentDeny fragmentVerdict = iota
// fragmentAllow passes the fragment: it belongs to an allowed datagram and
// does not overlap the already-inspected transport header.
fragmentAllow
// fragmentOverlap drops the fragment and poisons its datagram: it overlaps
// the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
// requires discarding the whole datagram on overlap for IPv6).
fragmentOverlap
)
// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
// 8200 reassembly key: source, destination, protocol and identification. The id
// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
type fragmentKey struct {
srcIP netip.Addr
dstIP netip.Addr
id uint32
proto uint8
}
// fragmentEntry records the verdict of an allowed first fragment.
type fragmentEntry struct {
// headerEndOctets is the offset, in 8-byte units, at which the first
// fragment's payload ended. A trailing fragment starting before this
// overlaps bytes the ACL already inspected and is rejected.
headerEndOctets uint16
// recordedAt is when the first fragment was accepted. The verdict expires a
// fixed timeout later and is not refreshed, mirroring the kernel reassembly
// timer so a trailing-fragment flood can't keep a datagram alive.
recordedAt time.Time
}
// fragmentTracker records the ACL verdict of a datagram's first fragment so the
// remaining fragments, which carry no L4 header, can inherit the decision
// without reassembling the datagram. Only allowed first fragments are stored;
// anything that cannot be tied to an allowed, non-overlapping first fragment is
// dropped (fail closed).
type fragmentTracker struct {
logger *nblog.Logger
mutex sync.Mutex
entries map[fragmentKey]fragmentEntry
timeout time.Duration
// maxEntries caps the table; atCapacity dedups the capacity warning until
// the table drains below the cap again.
maxEntries int
atCapacity bool
cleanupTicker *time.Ticker
cancel context.CancelFunc
}
func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
ctx, cancel := context.WithCancel(context.Background())
t := &fragmentTracker{
logger: logger,
entries: make(map[fragmentKey]fragmentEntry),
timeout: defaultFragmentTimeout,
maxEntries: fragmentMaxEntries(logger),
cleanupTicker: time.NewTicker(fragmentCleanupInterval),
cancel: cancel,
}
go t.cleanupRoutine(ctx)
return t
}
func fragmentMaxEntries(logger *nblog.Logger) int {
v := os.Getenv(EnvFragmentMaxEntries)
if v == "" {
return defaultMaxFragmentEntries
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
return defaultMaxFragmentEntries
}
return n
}
// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
// is the first fragment's payload length in 8-byte units. When the table is full
// the record is dropped, which fails closed: the datagram's trailing fragments
// will be denied.
func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.entries == nil {
return
}
if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
if !t.atCapacity {
t.atCapacity = true
t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
len(t.entries), t.maxEntries)
}
return
}
t.entries[key] = fragmentEntry{
headerEndOctets: headerEndOctets,
recordedAt: time.Now(),
}
}
// poison drops any recorded verdict for a datagram, so its later fragments are
// denied until a new allowed first fragment is recorded. Called on every
// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
func (t *fragmentTracker) poison(key fragmentKey) {
t.mutex.Lock()
defer t.mutex.Unlock()
delete(t.entries, key)
}
// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
// fragment offset, in 8-byte units). A fragment overlapping the inspected
// header poisons the datagram: the entry is removed so all further fragments of
// that datagram are denied too.
func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
t.mutex.Lock()
defer t.mutex.Unlock()
entry, ok := t.entries[key]
if !ok {
return fragmentDeny
}
if time.Since(entry.recordedAt) > t.timeout {
delete(t.entries, key)
return fragmentDeny
}
if fragOffsetOctets < entry.headerEndOctets {
delete(t.entries, key)
return fragmentOverlap
}
return fragmentAllow
}
func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
defer t.cleanupTicker.Stop()
for {
select {
case <-t.cleanupTicker.C:
t.cleanup()
case <-ctx.Done():
return
}
}
}
func (t *fragmentTracker) cleanup() {
t.mutex.Lock()
defer t.mutex.Unlock()
for key, entry := range t.entries {
if time.Since(entry.recordedAt) > t.timeout {
delete(t.entries, key)
}
}
if len(t.entries) < t.maxEntries {
t.atCapacity = false
}
}
// Close stops the cleanup routine and releases resources.
func (t *fragmentTracker) Close() {
t.cancel()
t.mutex.Lock()
t.entries = nil
t.mutex.Unlock()
}

View File

@@ -1,115 +0,0 @@
package uspfilter
import (
"encoding/binary"
"testing"
)
// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
// Packets are built once, outside the timed region, so the benchmark measures
// only pipeline cost, which is what an attacker can amplify.
func benchFilterInbound(b *testing.B, pkt []byte) {
b.Helper()
b.ReportAllocs()
b.SetBytes(int64(len(pkt)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := benchManager
m.filterInbound(pkt, len(pkt))
}
}
// benchManager is a package-level manager reused across fragment benchmarks so
// setup cost stays out of the timed region.
var benchManager *Manager
func setupBenchManager(b *testing.B) *Manager {
b.Helper()
m := newFragmentTestManager(b)
allowUDP(b, m, 8080)
// Disable conntrack so the allowed-first-fragment path measures transport
// decode + ACL every iteration instead of matching the connection tracked
// on the first iteration.
m.stateful = false
benchManager = m
return m
}
// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
// packet that passes the ACL. Fragment paths should stay comparable to this.
func BenchmarkInbound_NormalPacket(b *testing.B) {
setupBenchManager(b)
pkt := normalUDPPacket(b, 8080, 32)
benchFilterInbound(b, pkt)
}
// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
// transport decode + ACL evaluation + verdict record.
func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
setupBenchManager(b)
pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
benchFilterInbound(b, pkt)
}
// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
// path: a single map lookup after the first fragment is on record.
func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
m := setupBenchManager(b)
first := firstFragmentUDP(b, 0x3000, 8080, 32)
m.filterInbound(first, len(first))
pkt := trailingFragment(b, 0x3000, 5, false, 24)
benchFilterInbound(b, pkt)
}
// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
// attacker floods trailing fragments with no first fragment on record. Each is
// a map miss and must be cheap.
func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
setupBenchManager(b)
pkt := trailingFragment(b, 0x4000, 185, false, 40)
benchFilterInbound(b, pkt)
}
// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
// first fragment too small to decode a transport header.
func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
setupBenchManager(b)
pkt := trailingFragment(b, 0x5000, 0, true, 4)
benchFilterInbound(b, pkt)
}
// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
// verdict table: an attacker varies the datagram id on every packet so no first
// fragment ever matches. Verdict lookups always miss and nothing is recorded,
// so the table cannot grow. Each iteration rewrites the id field in place.
func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
setupBenchManager(b)
pkt := trailingFragment(b, 0x6000, 185, false, 40)
m := benchManager
b.ReportAllocs()
b.SetBytes(int64(len(pkt)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
// IPv4 identification field is at bytes 4:6.
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
m.filterInbound(pkt, len(pkt))
}
}
// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
// pressure with distinct ids: transport decode + ACL + verdict insert until the
// table caps, exercising the map growth and capacity guard.
func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
setupBenchManager(b)
pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
m := benchManager
b.ReportAllocs()
b.SetBytes(int64(len(pkt)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
m.filterInbound(pkt, len(pkt))
}
}

View File

@@ -1,554 +0,0 @@
package uspfilter
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/require"
fw "github.com/netbirdio/netbird/client/firewall/manager"
nbiface "github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
const (
fragTestSrc = "100.10.0.1"
fragTestDst = "100.10.0.100"
fragTestSrcV6 = "fd00::1"
fragTestDstV6 = "fd00::100"
)
func newFragmentTestManager(tb testing.TB) *Manager {
tb.Helper()
ifaceMock := &IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
AddressFunc: func() wgaddr.Address {
return wgaddr.Address{
IP: netip.MustParseAddr(fragTestDst),
Network: netip.MustParsePrefix("100.10.0.0/16"),
IPv6: netip.MustParseAddr(fragTestDstV6),
IPv6Net: netip.MustParsePrefix("fd00::/64"),
}
},
}
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
require.NoError(tb, err)
require.NoError(tb, m.UpdateLocalIPs())
tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
return m
}
// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
// the given destination: it carries the full UDP header plus payloadLen bytes
// of data, with the More Fragments flag set and offset zero.
func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
tb.Helper()
ip := &layers.IPv4{
Version: 4,
TTL: 64,
Id: id,
Protocol: layers.IPProtocolUDP,
SrcIP: net.ParseIP(fragTestSrc),
DstIP: net.ParseIP(dst),
Flags: layers.IPv4MoreFragments,
}
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
return buf.Bytes()
}
func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
tb.Helper()
return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
}
// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
// set and offset zero.
func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
tb.Helper()
ip := &layers.IPv4{
Version: 4,
TTL: 64,
Id: id,
Protocol: layers.IPProtocolTCP,
SrcIP: net.ParseIP(fragTestSrc),
DstIP: net.ParseIP(fragTestDst),
Flags: layers.IPv4MoreFragments,
}
tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
return buf.Bytes()
}
// trailingFragmentTo builds a non-first fragment to the given destination: an
// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
// payload and no L4 header.
func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
tb.Helper()
ip := &layers.IPv4{
Version: 4,
TTL: 64,
Id: id,
Protocol: proto,
SrcIP: net.ParseIP(fragTestSrc),
DstIP: net.ParseIP(dst),
FragOffset: fragOffsetOctets,
}
if moreFragments {
ip.Flags = layers.IPv4MoreFragments
}
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{FixLengths: true}
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
return buf.Bytes()
}
func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
tb.Helper()
return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
}
// outboundUDPPacket builds a complete outbound UDP packet from the local
// address, used to establish conntrack state for reply-direction tests.
func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
tb.Helper()
ip := &layers.IPv4{
Version: 4,
TTL: 64,
Id: 1,
Protocol: layers.IPProtocolUDP,
SrcIP: net.ParseIP(fragTestDst),
DstIP: net.ParseIP(fragTestSrc),
}
udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
return buf.Bytes()
}
// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
// comparisons against the fragment paths.
func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
tb.Helper()
ip := &layers.IPv4{
Version: 4,
TTL: 64,
Id: 1,
Protocol: layers.IPProtocolUDP,
SrcIP: net.ParseIP(fragTestSrc),
DstIP: net.ParseIP(fragTestDst),
}
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
return buf.Bytes()
}
func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
tb.Helper()
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
&fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
require.NoError(tb, err)
}
// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
// fragment with no allowed first fragment on record must be dropped. Before the
// fix, filterInbound returned false (allow) for any fragment.
func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
m := newFragmentTestManager(t)
frag := trailingFragment(t, 0x1234, 185, false, 40)
require.True(t, m.filterInbound(frag, len(frag)),
"trailing fragment without an allowed first fragment must be dropped")
}
// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
// passes the ACL, its trailing fragments inherit the allow verdict.
func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
m := newFragmentTestManager(t)
allowUDP(t, m, 8080)
// First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
first := firstFragmentUDP(t, 0x2222, 8080, 32)
require.False(t, m.filterInbound(first, len(first)),
"allowed first fragment should pass and be recorded")
trailing := trailingFragment(t, 0x2222, 5, false, 24)
require.False(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of an allowed datagram should pass")
}
// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
// by the ACL leaves no verdict, so its trailing fragments are dropped.
func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
m := newFragmentTestManager(t)
// No accept rule: local traffic defaults to deny.
first := firstFragmentUDP(t, 0x3333, 9999, 32)
require.True(t, m.filterInbound(first, len(first)),
"first fragment to a blocked port should be dropped by the ACL")
trailing := trailingFragment(t, 0x3333, 5, false, 24)
require.True(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of a denied datagram must be dropped")
}
// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
// overlapping-fragment rewrite: a trailing fragment starting inside the range
// the ACL already inspected is dropped and poisons the datagram. TCP is used so
// the overlap lands on real header bytes (the flags at byte 13).
func TestFragment_OverlappingHeaderDropped(t *testing.T) {
m := newFragmentTestManager(t)
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
require.NoError(t, err)
// First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
first := firstFragmentTCP(t, 0x4444, 8080)
require.False(t, m.filterInbound(first, len(first)))
// Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
// header, so it could rewrite the flags or port on reassembly.
overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
require.True(t, m.filterInbound(overlap, len(overlap)),
"fragment overlapping the inspected header must be dropped")
// The datagram is now poisoned: a later, non-overlapping fragment is also
// dropped because the verdict was removed.
later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
require.True(t, m.filterInbound(later, len(later)),
"fragments after an overlap must be dropped (datagram poisoned)")
}
// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
// an allowed first fragment followed by a denied offset-zero fragment for the
// same datagram must not leave the earlier allow verdict in place.
func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
m := newFragmentTestManager(t)
allowUDP(t, m, 8080)
allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
require.False(t, m.filterInbound(allowed, len(allowed)),
"allowed first fragment should pass and be recorded")
// A second offset-zero fragment to a denied port supersedes the datagram's
// verdict; it is dropped and must not leave the allow in place.
denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
require.True(t, m.filterInbound(denied, len(denied)),
"denied offset-zero fragment must be dropped")
trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
require.True(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment must be denied after the datagram was poisoned")
}
// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
// fragment too small to contain the full transport header can't be
// ACL-evaluated and must be dropped.
func TestFragment_TinyFirstDropped(t *testing.T) {
m := newFragmentTestManager(t)
allowUDP(t, m, 8080)
// IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
// header, so it decodes to L3 only.
tiny := trailingFragment(t, 0x5555, 0, true, 4)
require.True(t, m.filterInbound(tiny, len(tiny)),
"tiny first fragment without a full L4 header must be dropped")
}
// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
// trailing fragments inherit the verdict.
func TestFragment_TCPFirstFragment(t *testing.T) {
m := newFragmentTestManager(t)
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
require.NoError(t, err)
// TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
first := firstFragmentTCP(t, 0x6666, 8080)
require.False(t, m.filterInbound(first, len(first)),
"allowed TCP first fragment should pass and be recorded")
trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
require.False(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of an allowed TCP datagram should pass")
}
// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
func TestFragment_TCPTinyFirstDropped(t *testing.T) {
m := newFragmentTestManager(t)
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
require.NoError(t, err)
tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
require.True(t, m.filterInbound(tiny, len(tiny)),
"first fragment shorter than the TCP header must be dropped")
}
// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
// fragments of an outbound-established UDP flow pass without any inbound rule.
func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
m := newFragmentTestManager(t)
out := outboundUDPPacket(t, 12345, 40000)
require.False(t, m.filterOutbound(out, len(out)))
first := firstFragmentUDP(t, 0x8888, 12345, 32)
require.False(t, m.filterInbound(first, len(first)),
"reply first fragment should pass via conntrack")
trailing := trailingFragment(t, 0x8888, 5, false, 24)
require.False(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of a tracked flow should pass")
}
// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
// dropped when routing is disabled.
func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
m := newFragmentTestManager(t)
m.routingEnabled.Store(false)
first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
require.True(t, m.filterInbound(first, len(first)),
"routed first fragment must be dropped when routing is disabled")
}
// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
// destination follow the route rules, allowed datagrams pass their trailing
// fragments and denied ones don't.
func TestFragment_RouteACL(t *testing.T) {
m := newFragmentTestManager(t)
m.routingEnabled.Store(true)
m.nativeRouter.Store(false)
_, err := m.AddRouteFiltering(
[]byte("rt-1"),
[]netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
fw.ProtocolUDP,
nil,
&fw.Port{Values: []uint16{8080}},
fw.ActionAccept,
)
require.NoError(t, err)
first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
require.False(t, m.filterInbound(first, len(first)),
"route-ACL-allowed first fragment should pass")
trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
require.False(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of an allowed routed datagram should pass")
denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
require.True(t, m.filterInbound(denied, len(denied)),
"route-ACL-denied first fragment must be dropped")
deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
"trailing fragment of a denied routed datagram must be dropped")
}
// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
// tracker timeout no longer admits trailing fragments.
func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
m := newFragmentTestManager(t)
allowUDP(t, m, 8080)
first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
require.False(t, m.filterInbound(first, len(first)))
m.fragments.mutex.Lock()
for key, entry := range m.fragments.entries {
entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
m.fragments.entries[key] = entry
}
m.fragments.mutex.Unlock()
trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
require.True(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment after verdict expiry must be dropped")
}
// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
// datagram verdicts are not recorded (their trailing fragments are dropped)
// while already-recorded datagrams keep working.
func TestFragment_CapacityFailsClosed(t *testing.T) {
m := newFragmentTestManager(t)
allowUDP(t, m, 8080)
m.fragments.mutex.Lock()
m.fragments.maxEntries = 1
m.fragments.mutex.Unlock()
first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
require.False(t, m.filterInbound(first1, len(first1)))
first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
require.False(t, m.filterInbound(first2, len(first2)),
"first fragment itself still passes at capacity")
trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
require.True(t, m.filterInbound(trailing2, len(trailing2)),
"trailing fragment of an unrecorded datagram must be dropped at capacity")
trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
require.False(t, m.filterInbound(trailing1, len(trailing1)),
"already-recorded datagram should keep passing at capacity")
}
// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
// given inner protocol, offset (8-byte units), More Fragments bit and id.
func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
offsetFlags := offsetOctets << 3
if moreFragments {
offsetFlags |= 1
}
hdr := make([]byte, 8)
hdr[0] = uint8(proto)
binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
binary.BigEndian.PutUint32(hdr[4:8], id)
return hdr
}
func v6UDPHeader(dstPort uint16, dataLen int) []byte {
hdr := make([]byte, 8)
binary.BigEndian.PutUint16(hdr[0:2], 40000)
binary.BigEndian.PutUint16(hdr[2:4], dstPort)
binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
return hdr
}
// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
// data.
func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
tb.Helper()
return fragmentUDPv6(tb, id, dstPort, dataLen, true)
}
// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
// false it is an atomic fragment (a complete datagram, RFC 6946).
func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
tb.Helper()
ip := &layers.IPv6{
Version: 6,
NextHeader: layers.IPProtocolIPv6Fragment,
HopLimit: 64,
SrcIP: net.ParseIP(fragTestSrcV6),
DstIP: net.ParseIP(fragTestDstV6),
}
payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
payload = append(payload, make([]byte, dataLen)...)
buf := gopacket.NewSerializeBuffer()
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
return buf.Bytes()
}
// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
// given offset carrying raw data and no transport header.
func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
tb.Helper()
ip := &layers.IPv6{
Version: 6,
NextHeader: layers.IPProtocolIPv6Fragment,
HopLimit: 64,
SrcIP: net.ParseIP(fragTestSrcV6),
DstIP: net.ParseIP(fragTestDstV6),
}
payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
buf := gopacket.NewSerializeBuffer()
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
return buf.Bytes()
}
// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
// a trailing fragment with no allowed first fragment is dropped.
func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
m := newFragmentTestManager(t)
frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
require.True(t, m.filterInbound(frag, len(frag)),
"IPv6 trailing fragment without an allowed first fragment must be dropped")
}
// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
// evaluated like IPv4: an allowed first fragment lets its trailing fragments
// through.
func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
m := newFragmentTestManager(t)
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
require.NoError(t, err)
// First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
require.False(t, m.filterInbound(first, len(first)),
"allowed IPv6 first fragment should pass and be recorded")
trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
require.False(t, m.filterInbound(trailing, len(trailing)),
"trailing fragment of an allowed IPv6 datagram should pass")
}
// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
// is evaluated but not recorded, so a flood of allowed atomic fragments can't
// exhaust the verdict table.
func TestFragmentV6_AtomicNotCached(t *testing.T) {
m := newFragmentTestManager(t)
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
require.NoError(t, err)
atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
require.False(t, m.filterInbound(atomic, len(atomic)),
"allowed IPv6 atomic fragment should pass")
m.fragments.mutex.Lock()
n := len(m.fragments.entries)
m.fragments.mutex.Unlock()
require.Zero(t, n, "atomic fragment must not create a verdict entry")
// A genuine fragmented datagram (More Fragments set) is still recorded.
first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
require.False(t, m.filterInbound(first, len(first)))
m.fragments.mutex.Lock()
n = len(m.fragments.entries)
m.fragments.mutex.Unlock()
require.Equal(t, 1, n, "genuine first fragment must record a verdict")
}

View File

@@ -3,31 +3,14 @@
package netstack
import (
"net"
"fmt"
"os"
"strconv"
log "github.com/sirupsen/logrus"
)
const (
EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
// EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
// EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
// The proxy is a bridge for local host applications into the userspace
// WireGuard netstack, so it binds to loopback by default. Override this only
// when the proxy must be reachable from other hosts (e.g. a container
// gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
// address.
EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
// defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
// overridden via EnvSocks5ListenerAddress.
defaultSocks5Host = "127.0.0.1"
)
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
// IsEnabled todo: move these function to cmd layer
func IsEnabled() bool {
@@ -35,40 +18,24 @@ func IsEnabled() bool {
}
func ListenAddr() string {
return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
}
// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
func listenHost() string {
addr := os.Getenv(EnvSocks5ListenerAddress)
if addr == "" {
return defaultSocks5Host
}
if net.ParseIP(addr) == nil {
log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
return defaultSocks5Host
}
return addr
}
// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
func listenPort() int {
sPort := os.Getenv(EnvSocks5ListenerPort)
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
if sPort == "" {
return DefaultSocks5Port
return listenAddr(DefaultSocks5Port)
}
port, err := strconv.Atoi(sPort)
if err != nil {
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
return DefaultSocks5Port
return listenAddr(DefaultSocks5Port)
}
if port < 1 || port > 65535 {
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
return DefaultSocks5Port
return listenAddr(DefaultSocks5Port)
}
return port
return listenAddr(port)
}
func listenAddr(port int) string {
return fmt.Sprintf("0.0.0.0:%d", port)
}

View File

@@ -1,63 +0,0 @@
//go:build !js
package netstack
import (
"net"
"strconv"
"testing"
)
func TestListenAddr_DefaultsToLoopback(t *testing.T) {
// No env overrides: must bind loopback, never all interfaces.
got := ListenAddr()
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
if got != want {
t.Fatalf("ListenAddr() = %q, want %q", got, want)
}
}
func TestListenAddr_AddressOverride(t *testing.T) {
tests := []struct {
name string
env string
want string
}{
{name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
{name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
{name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
{name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
{name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(EnvSocks5ListenerAddress, tc.env)
want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
if got := ListenAddr(); got != want {
t.Fatalf("ListenAddr() = %q, want %q", got, want)
}
})
}
}
func TestListenAddr_PortOverride(t *testing.T) {
tests := []struct {
name string
env string
want int
}{
{name: "valid port honored", env: "1081", want: 1081},
{name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
{name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
{name: "zero falls back", env: "0", want: DefaultSocks5Port},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(EnvSocks5ListenerPort, tc.env)
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
if got := ListenAddr(); got != want {
t.Fatalf("ListenAddr() = %q, want %q", got, want)
}
})
}
}

View File

@@ -299,7 +299,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
UseIDToken: d.providerConfig.UseIDToken,
}
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}

View File

@@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
audience = p.providerConfig.ClientID
}
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
}
@@ -320,11 +320,6 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
return tokenInfo, nil
}
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
// without verifying its signature. The value is best-effort and used only as a
// UX convenience (login hint prefill and display); it never drives an
// authorization decision. The authoritative identity is established server-side
// from the signature-verified token.
func parseEmailFromIDToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {

View File

@@ -24,7 +24,11 @@ import (
)
const (
maxPastHorizon = 30 * 24 * time.Hour
// Skew tolerates a small clock difference between the management
// server and this peer before treating a deadline as "in the past".
// Slightly above typical NTP drift; tight enough that the UI doesn't
// paint a stale expiry as if it were valid.
Skew = 30 * time.Second
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
@@ -53,7 +57,7 @@ var (
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
// ErrDeadlineInPast is returned by Update when the supplied deadline
// is more than maxPastHorizon in the past.
// is more than Skew in the past.
ErrDeadlineInPast = errors.New("session deadline in the past")
)
@@ -62,14 +66,15 @@ var (
// for deadline change/clear, PublishEvent for the two warnings); tests pass
// a fake recorder so the same surface is observable without an engine.
//
// While the watcher runs, it owns the deadline propagated to the recorder:
// every set, clear and sanity-check rejection routes the value through
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
// out its own state-change notification, so no separate notify is needed.)
// The recorder is server-scoped and outlives this engine-scoped watcher;
// Close deliberately leaves the recorder value in place so transient engine
// restarts don't blank it — the client run loop clears it on real teardown.
// The watcher is the single owner of the deadline propagated to the
// recorder: every set, clear, sanity-check rejection and Close routes the
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
// fans out its own state-change notification, so no separate notify is
// needed.) The recorder is server-scoped and outlives this engine-scoped
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
// a profile switch) would leave the next session showing the previous one's
// stale "expires in" value.
//
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
// composes the metadata internally so the wire format (MetaSession*) is
@@ -130,13 +135,10 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
// was disabled).
//
// Same-value updates are no-ops. A different non-zero value cancels any
// pending timer, resets the "already fired" guards, and — when the
// deadline lies in the future — arms fresh warning timers. A deadline
// already in the past (within maxPastHorizon) is recorded as-is with no
// timers: the session has expired and consumers render it that way.
// pending timer, resets the "already fired" guard, and arms a new one.
//
// Returns one of the sentinel Err* values when the deadline fails the
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
// In every error case the watcher first clears its state so it stays
// consistent with what the caller will push into its other sinks (e.g.
// applySessionDeadline forces a zero deadline into the status recorder
@@ -161,7 +163,7 @@ func (w *Watcher) Update(deadline time.Time) error {
case deadline.After(now.Add(maxDeadlineHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
case deadline.Before(now.Add(-maxPastHorizon)):
case deadline.Before(now.Add(-Skew)):
w.clearLocked()
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
}
@@ -181,9 +183,7 @@ func (w *Watcher) Update(deadline time.Time) error {
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
if deadline.After(now) {
w.armTimerLocked(deadline)
}
w.armTimerLocked(deadline)
recorder := w.recorder
w.mu.Unlock()
if recorder != nil {
@@ -227,25 +227,30 @@ func (w *Watcher) Dismiss() {
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
}
// Close stops any pending timer. Update calls after Close are ignored.
// The recorder keeps its deadline: the watcher is engine-scoped and closes
// on every engine restart (network change, sleep/wake, stream errors)
// while the SSO deadline stays valid across those, so clearing here would
// blank the UI's "expires in" row on every transient reconnect. The
// client run loop clears the server-scoped recorder when it exits for
// real (Down, profile switch, permanent login failure).
// Close stops any pending timer and drops the deadline on the status
// recorder. Update calls after Close are ignored. Clearing the recorder
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
// from leaving the next session showing this one's stale "expires in"
// value — the recorder is server-scoped and outlives this engine-scoped
// watcher, so nothing else drops the anchor on teardown.
func (w *Watcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
w.mu.Unlock()
return
}
w.closed = true
w.stopTimerLocked()
hadDeadline := !w.current.IsZero()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil && hadDeadline {
recorder.SetSessionExpiresAt(time.Time{})
}
}
// clearLocked drops the tracked deadline and notifies the recorder so

View File

@@ -224,13 +224,11 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
r := &fakeRecorder{}
lead := 150 * time.Millisecond
lead := 30 * time.Millisecond
w := newWatcher(lead, r)
defer w.Close()
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
// replacement below lands well before it.
first := time.Now().Add(170 * time.Millisecond)
first := time.Now().Add(50 * time.Millisecond)
_ = w.Update(first)
// Wait for stateChange + warning of the first cycle.
@@ -308,29 +306,7 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
}
}
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(-1 * time.Hour)
if err := w.Update(d); err != nil {
t.Fatalf("recent-past Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
}
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline = %v, want %v", got, d)
}
time.Sleep(80 * time.Millisecond)
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
}
}
func TestUpdateAncientPastRejected(t *testing.T) {
func TestUpdateInPastClearsDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
@@ -342,12 +318,12 @@ func TestUpdateAncientPastRejected(t *testing.T) {
// Drain the stateChange from the seed.
waitForEvents(t, r, 1)
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
err := w.Update(time.Now().Add(-1 * time.Hour))
if !errors.Is(err, ErrDeadlineInPast) {
t.Fatalf("want ErrDeadlineInPast, got %v", err)
}
if !w.Deadline().IsZero() {
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
}
events := waitForEvents(t, r, 2)
if events[1].kind != stateChange {
@@ -355,25 +331,39 @@ func TestUpdateAncientPastRejected(t *testing.T) {
}
}
func TestUpdateWithinSkewAccepted(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
d := time.Now().Add(-5 * time.Second)
if err := w.Update(d); err != nil {
t.Fatalf("within-skew Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
}
}
func TestCloseSilencesUpdates(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
w.Close()
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
t.Fatalf("Update after Close: want nil, got %v", err)
}
_ = w.Update(time.Now().Add(time.Hour))
time.Sleep(20 * time.Millisecond)
if got := r.snapshot(); len(got) != 0 {
t.Fatalf("expected no events after Close, got %+v", got)
}
}
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
// closes on every engine restart (network change, sleep/wake) while the
// SSO deadline stays valid across those, so Close must leave the
// server-scoped recorder's value in place. The client run loop clears the
// recorder when it exits for real.
func TestCloseKeepsRecorderDeadline(t *testing.T) {
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
// holding a live deadline must zero the recorder on Close so the next
// engine's watcher (and the UI reading the shared server-scoped recorder)
// doesn't start out showing the previous session's stale "expires in".
func TestCloseClearsRecorderDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r)
@@ -387,8 +377,8 @@ func TestCloseKeepsRecorderDeadline(t *testing.T) {
w.Close()
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
if got := r.deadline(); !got.IsZero() {
t.Fatalf("recorder deadline after Close = %v, want zero", got)
}
}

View File

@@ -20,26 +20,14 @@ func randomBytesInHex(count int) (string, error) {
return hex.EncodeToString(buf), nil
}
// validateTokenAudience checks that the token is a well-formed JWT whose
// audience claim matches the expected audience.
//
// It does NOT verify the token's cryptographic signature and therefore must not
// be treated as an authenticity check. The token is obtained by the client
// directly from the IdP token endpoint over TLS, and its signature is verified
// server-side by the management server against the IdP's JWKS
// (see shared/auth/jwt/validator.go). This function is only a client-side
// sanity check that the returned token targets the expected audience.
func validateTokenAudience(token string, audience string) error {
// isValidAccessToken is a simple validation of the access token
func isValidAccessToken(token string, audience string) error {
if token == "" {
return fmt.Errorf("token received is empty")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return fmt.Errorf("token is not a well-formed JWT")
}
claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
encodedClaims := strings.Split(token, ".")[1]
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
if err != nil {
return err
}

View File

@@ -1,108 +0,0 @@
package auth
import (
"encoding/base64"
"encoding/json"
"testing"
)
// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with
// the given claims payload. The signature part is arbitrary because
// validateTokenAudience intentionally does not verify it.
func makeJWT(t *testing.T, claims map[string]interface{}) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
payloadBytes, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
return header + "." + payload + ".unverified-signature"
}
func TestValidateTokenAudience(t *testing.T) {
tests := []struct {
name string
token string
audience string
wantErr bool
}{
{
name: "empty token",
token: "",
audience: "netbird",
wantErr: true,
},
{
name: "not a JWT - no dots",
token: "notajwt",
audience: "netbird",
wantErr: true,
},
{
name: "not a JWT - two parts only",
token: "header.payload",
audience: "netbird",
wantErr: true,
},
{
name: "matching string audience",
token: makeJWT(t, map[string]interface{}{"aud": "netbird"}),
audience: "netbird",
wantErr: false,
},
{
name: "mismatching string audience",
token: makeJWT(t, map[string]interface{}{"aud": "other"}),
audience: "netbird",
wantErr: true,
},
{
name: "matching audience in array",
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}),
audience: "netbird",
wantErr: false,
},
{
name: "mismatching audience array",
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}),
audience: "netbird",
wantErr: true,
},
{
name: "missing audience claim",
token: makeJWT(t, map[string]interface{}{"sub": "user"}),
audience: "netbird",
wantErr: true,
},
{
name: "invalid base64 payload",
token: "header.!!!not-base64!!!.sig",
audience: "netbird",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateTokenAudience(tc.token, tc.audience)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
}
// TestValidateTokenAudienceNoPanic guards the regression where a non-empty
// token without the JWT dot structure caused an index-out-of-range panic.
func TestValidateTokenAudienceNoPanic(t *testing.T) {
inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"}
for _, in := range inputs {
if err := validateTokenAudience(in, "netbird"); err == nil {
t.Fatalf("expected error for malformed token %q, got nil", in)
}
}
}

View File

@@ -257,10 +257,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
defer func() {
c.statusRecorder.SetSessionExpiresAt(time.Time{})
c.statusRecorder.ClientStop()
}()
defer c.statusRecorder.ClientStop()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {

View File

@@ -52,14 +52,11 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}

View File

@@ -50,11 +50,5 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}

View File

@@ -75,14 +75,4 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
"invalid timestamp must clear the deadline")
})
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
e := newEngine()
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
e.ApplySessionDeadline(timestamppb.New(expired))
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
"recently-expired deadline must stay on the recorder so consumers render it as expired")
})
}

View File

@@ -813,14 +813,19 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
}
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
// or the zero value when no deadline is tracked. A deadline in the past is
// returned as-is: it means the session has expired, and consumers (tray row,
// CLI status) render it as "expired" rather than hiding it — masking it as
// "none" would blank the UI at the exact moment it should say the session
// ended.
// or the zero value when no deadline is tracked. A deadline that has already
// slipped into the past reports as "none": once the session has expired it is
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
// arm a timer at the deadline itself to clear it (only the two pre-expiry
// warnings). Without this guard the UI would keep painting a stale
// "expires in …" against a moment that has passed until the next login,
// extend, or teardown rewrote the value.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
return time.Time{}
}
return d.sessionExpiresAt
}

View File

@@ -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 {

View File

@@ -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())
}

View File

@@ -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)

View File

@@ -1,7 +1,6 @@
package statemanager
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -306,11 +305,6 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
var rawStates map[string]json.RawMessage
if err := json.Unmarshal(data, &rawStates); err != nil {
if len(bytes.TrimSpace(data)) == 0 {
log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data))
} else {
log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data))
}
m.handleCorruptedState(deleteCorrupt)
return nil, fmt.Errorf("unmarshal states: %w", err)
}

View File

@@ -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
}

View File

@@ -1,71 +1,27 @@
#!/usr/bin/env bash
# Runs the NetBird daemon and brings the connection up in one container process.
#
# A thin wrapper is needed (rather than a one-line ENTRYPOINT) for two reasons:
# 1. Two processes must run: the daemon (`service run`, long-lived) and a
# one-shot `up` that brings the connection up.
# 2. Signal handling: as PID 1 the wrapper must forward SIGTERM/SIGINT to the
# daemon so it tears down WireGuard and deregisters ephemeral peers on
# `docker stop`. Without this the daemon would be killed uncleanly.
#
# `netbird up` waits for the daemon to become ready on its own, so no readiness
# poll is needed here.
set -eEuo pipefail
: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="30"}
NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}"
export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}"
service_pids=()
_log() {
# mimic Go logger's output for easier parsing
# 2025-04-15T21:32:00+08:00 INFO client/internal/config.go:495: setting notifications to disabled by default
printf "$(date -Isec) ${1} ${BASH_SOURCE[1]}:${BASH_LINENO[1]}: ${2}\n" "${@:3}" >&2
}
daemon=""
cleanup() { [[ -n "${daemon}" ]] && kill -TERM "${daemon}" 2>/dev/null || true; }
trap cleanup SIGTERM SIGINT EXIT
info() {
_log INFO "$@"
}
"${NETBIRD_BIN}" service run &
daemon=$!
warn() {
_log WARN "$@"
}
"${NETBIRD_BIN}" up
on_exit() {
info "Shutting down NetBird daemon..."
if test "${#service_pids[@]}" -gt 0; then
info "terminating service process IDs: ${service_pids[@]@Q}"
kill -TERM "${service_pids[@]}" 2>/dev/null || true
wait "${service_pids[@]}" 2>/dev/null || true
else
info "there are no service processes to terminate"
fi
}
wait_for_daemon_startup() {
local timeout="${1}"
if [[ "${timeout}" -eq 0 ]]; then
info "not waiting for daemon startup due to zero timeout."
return
fi
local deadline=$((SECONDS + timeout))
while [[ "${SECONDS}" -lt "${deadline}" ]]; do
if "${NETBIRD_BIN}" status --check live 2>/dev/null; then
return
fi
sleep 1
done
warn "daemon did not become responsive after ${timeout} seconds, exiting..."
exit 1
}
connect() {
info "running 'netbird up'..."
"${NETBIRD_BIN}" up
return $?
}
main() {
trap 'on_exit' SIGTERM SIGINT EXIT
"${NETBIRD_BIN}" service run &
service_pids+=("$!")
info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}"
wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}"
connect
wait "${service_pids[@]}"
}
main "$@"
wait "${daemon}"

View File

@@ -995,8 +995,13 @@ type StatusResponse struct {
// Unset when the peer is not SSO-registered or login expiration is disabled.
// The UI derives "warning active" from this value and its own clock.
SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// daemonReady reports whether the daemon has finished starting up and is
// serving RPCs backed by a running engine. Older daemons never set this
// (it defaults to false); clients must treat an unset value as "unknown"
// and fall back to their previous readiness heuristics for compatibility.
DaemonReady bool `protobuf:"varint,5,opt,name=daemonReady,proto3" json:"daemonReady,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusResponse) Reset() {
@@ -1057,6 +1062,13 @@ func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
return nil
}
func (x *StatusResponse) GetDaemonReady() bool {
if x != nil {
return x.DaemonReady
}
return false
}
type DownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -7083,14 +7095,15 @@ const file_daemon_proto_rawDesc = "" +
"\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" +
"\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" +
"\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" +
"\r_waitForReady\"\xca\x01\n" +
"\r_waitForReady\"\xec\x01\n" +
"\x0eStatusResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\x122\n" +
"\n" +
"fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" +
"fullStatus\x12$\n" +
"\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" +
"\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" +
"\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\x12 \n" +
"\vdaemonReady\x18\x05 \x01(\bR\vdaemonReady\"\r\n" +
"\vDownRequest\"\x0e\n" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +

View File

@@ -291,6 +291,11 @@ message StatusResponse{
// Unset when the peer is not SSO-registered or login expiration is disabled.
// The UI derives "warning active" from this value and its own clock.
google.protobuf.Timestamp sessionExpiresAt = 4;
// daemonReady reports whether the daemon has finished starting up and is
// serving RPCs backed by a running engine. Older daemons never set this
// (it defaults to false); clients must treat an unset value as "unknown"
// and fall back to their previous readiness heuristics for compatibility.
bool daemonReady = 5;
}
message DownRequest {}

View File

@@ -104,6 +104,12 @@ type Server struct {
persistSyncResponse bool
isSessionActive atomic.Bool
// ready is set once the daemon has finished startup and is serving RPCs
// backed by a running engine (see SetReady, called after Start succeeds and
// the service is registered). Reported via StatusResponse.DaemonReady so
// clients can wait deterministically instead of polling heuristically.
ready atomic.Bool
cpuProfileBuf *bytes.Buffer
cpuProfiling bool
@@ -158,6 +164,14 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
return s
}
// SetReady marks the daemon as fully started and serving RPCs. It is called by
// the service controller once Start has succeeded and the DaemonService is
// registered, so a subsequent Status RPC reports DaemonReady=true. Safe for
// concurrent use.
func (s *Server) SetReady() {
s.ready.Store(true)
}
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -1081,10 +1095,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 +1168,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
}
}
@@ -1425,7 +1436,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
s.isSessionActive.Store(false)
}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion(), DaemonReady: s.ready.Load()}
if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
statusResponse.SessionExpiresAt = timestamppb.New(deadline)

View File

@@ -14,7 +14,6 @@ import (
log "github.com/sirupsen/logrus"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/shared/management/domain"
)
const (
@@ -219,20 +218,11 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
if peer.IPv6.IsValid() {
hostPatterns = append(hostPatterns, peer.IPv6.String())
}
// Peer FQDNs and hostnames originate from remote peers, so they must be
// validated as plain DNS names before being embedded in the ssh_config
// "Match host" pattern list. This prevents injection of arbitrary
// ssh_config directives via embedded quotes, whitespace, newlines, the
// comma pattern separator, or the "*"/"?" pattern metacharacters.
if domain.IsValidDomainNoWildcard(peer.FQDN) {
if peer.FQDN != "" {
hostPatterns = append(hostPatterns, peer.FQDN)
} else if peer.FQDN != "" {
log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN)
}
if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
if peer.Hostname != "" && peer.Hostname != peer.FQDN {
hostPatterns = append(hostPatterns, peer.Hostname)
} else if peer.Hostname != "" && peer.Hostname != peer.FQDN {
log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname)
}
return hostPatterns
}

View File

@@ -148,45 +148,6 @@ func TestManager_MatchHostFormat(t *testing.T) {
"should use Match host with comma-separated patterns")
}
func TestManager_HostPatternInjection(t *testing.T) {
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }()
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
}
// A malicious peer FQDN/hostname attempts to break out of the Match host
// directive and inject arbitrary ssh_config (a ProxyCommand executing a
// command). It must be rejected, not written to the config.
peers := []PeerSSHInfo{
{
Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x",
IP: netip.MustParseAddr("100.125.1.1"),
FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal",
},
{Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"},
}
err = manager.SetupSSHClientConfig(peers)
require.NoError(t, err)
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
content, err := os.ReadFile(configPath)
require.NoError(t, err)
configStr := string(content)
assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned",
"injected directive must not appear in generated config")
assert.NotContains(t, configStr, "evil",
"malicious pattern must be dropped entirely")
// The valid peer must still be present, on a single Match host line.
assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"",
"valid peers must survive, injected patterns dropped")
}
func TestManager_ForcedSSHConfig(t *testing.T) {
// Set force environment variable
t.Setenv(EnvForceSSHConfig, "true")

View File

@@ -69,8 +69,7 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
// rejected so the input can never be parsed as a command-line flag.
// (@ for Kerberos, $ for Samba, + for NIS compat).
func validateGetentInput(input string) bool {
maxLen := 32
if runtime.GOOS == "linux" {
@@ -81,10 +80,6 @@ func validateGetentInput(input string) bool {
return false
}
if input[0] == '-' {
return false
}
for _, r := range input {
if isAllowedGetentChar(r) {
continue

View File

@@ -157,9 +157,6 @@ func TestValidateGetentInput(t *testing.T) {
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
{"leading hyphen rejected", "-i", false},
{"leading double hyphen rejected", "--no-idn", false},
{"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},

View File

@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
// netbirdFootprintExists reports whether the machine already carries NetBird
// daemon config or state, meaning this is not a genuinely fresh install. It is
// the update-safety gate for the autostart default: upgrading users always
// have a footprint, so an update can never trigger a autostart entry write.
// have a footprint, so an update can never trigger a login-item write.
func netbirdFootprintExists() bool {
candidates := []string{
profilemanager.DefaultConfigPath,
@@ -69,23 +69,9 @@ func netbirdFootprintExists() bool {
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
// fresh installs. The autostartInitialized marker is persisted before any
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
// retrying autostart entry writes on every launch. A user's later disable in
// retrying login-item writes on every launch. A user's later disable in
// Settings is never overridden: the marker guarantees at-most-once, ever.
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
if mdmDisabled {
if enabled, err := autostart.IsEnabled(ctx); err != nil {
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
} else if enabled {
if err := autostart.SetEnabled(ctx, false); err != nil {
log.Warnf("MDM disableAutostart: force off failed: %v", err)
} else {
log.Info("MDM disableAutostart enforced: autostart turned off")
}
}
}
priorFootprint := netbirdFootprintExists() || prefsFileExisted
if prefs.Get().AutostartInitialized {
@@ -98,7 +84,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
state := autostartDefaultState{
supported: autostart.Supported(ctx),
mdmDisabled: mdmDisabled,
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
priorInstall: priorFootprint,
}
enable, reason := shouldEnableAutostartDefault(state)

View File

@@ -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>

View File

@@ -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,

View File

@@ -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);
});
};

View File

@@ -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]);

View File

@@ -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>

View File

@@ -8,7 +8,6 @@
{"code": "fr", "displayName": "Français", "englishName": "French"},
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}
]
}

View File

@@ -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."

View File

@@ -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.",

View File

@@ -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."

View File

@@ -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 licô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 licô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 : lhorloge de cet appareil nest pas synchronisée avec le serveur. Veuillez synchroniser lhorloge de votre système et réessayer."

View File

@@ -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."

View File

@@ -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."

File diff suppressed because it is too large Load Diff

View File

@@ -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."

View File

@@ -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": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."

View File

@@ -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": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"

View File

@@ -10,7 +10,7 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// Autostart facade over Wails' AutostartManager. The OS autostart entry registration
// Autostart facade over Wails' AutostartManager. The OS login-item registration
// is the single source of truth; nothing is mirrored to preferences.
type Autostart struct {
mgr *application.AutostartManager

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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"
@@ -317,7 +315,8 @@ func (t *Tray) relayoutMenu() {
if sessionDeadline.IsZero() {
t.sessionExpiresItem.SetHidden(true)
} else {
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline))
remaining := t.formatSessionRemaining(time.Until(sessionDeadline))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetHidden(false)
}
}
@@ -447,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) {

View File

@@ -64,42 +64,11 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool {
return changed
}
// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit.
// The interval scales with the remaining time: coarse when the deadline is far off,
// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded
// countdown near expiry. The cached deadline is re-read every iteration, so an extend
// or reconnect that moves it is picked up on the next tick.
// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit.
func (t *Tray) runSessionExpiryTicker() {
tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining()))
defer tm.Stop()
for range tm.C {
tk := time.NewTicker(30 * time.Second)
for range tk.C {
t.refreshSessionExpiresLabel()
tm.Reset(sessionRefreshInterval(t.sessionRemaining()))
}
}
// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown.
func (t *Tray) sessionRemaining() time.Duration {
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return 0
}
return time.Until(deadline)
}
// sessionRefreshInterval picks how long to wait before the next label recompute.
func sessionRefreshInterval(remaining time.Duration) time.Duration {
switch {
case remaining <= 0:
return 30 * time.Second
case remaining <= 2*time.Minute:
return 10 * time.Second
case remaining <= time.Hour:
return 30 * time.Second
default:
return time.Minute
}
}
@@ -118,39 +87,30 @@ func (t *Tray) refreshSessionExpiresLabel() {
if deadline.IsZero() {
return
}
item.SetLabel(t.sessionRowLabel(deadline))
}
func (t *Tray) sessionRowLabel(deadline time.Time) string {
remaining := time.Until(deadline)
if remaining <= 0 {
return t.loc.T("tray.status.sessionExpired")
}
return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining))
remaining := t.formatSessionRemaining(time.Until(deadline))
item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
}
// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit.
// Each unit is rounded up so the label never claims less time than actually remains, matching the
// upper-bound sense of the sub-minute "less than a minute" fragment.
// Singular/plural keys are split per language for proper translation.
func (t *Tray) formatSessionRemaining(d time.Duration) string {
switch {
case d < time.Minute:
return t.loc.T("tray.session.unit.lessThanMinute")
case d <= 59*time.Minute:
m := ceilDiv(d, time.Minute)
case d < time.Hour:
m := int(d / time.Minute)
if m == 1 {
return t.loc.T("tray.session.unit.minute")
}
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
case d <= 23*time.Hour:
h := ceilDiv(d, time.Hour)
case d < 24*time.Hour:
h := int((d + 30*time.Minute) / time.Hour)
if h == 1 {
return t.loc.T("tray.session.unit.hour")
}
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
default:
days := ceilDiv(d, 24*time.Hour)
days := int((d + 12*time.Hour) / (24 * time.Hour))
if days == 1 {
return t.loc.T("tray.session.unit.day")
}
@@ -158,11 +118,6 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string {
}
}
// ceilDiv divides d by unit rounding up, assuming d > 0.
func ceilDiv(d, unit time.Duration) int {
return int((d + unit - time.Nanosecond) / unit)
}
// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning.
// Errors are swallowed since the worst case is a plain notification without buttons.
func (t *Tray) registerSessionWarningCategory() {
@@ -297,9 +252,11 @@ func (t *Tray) openSessionExpiration() {
}
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the
// click routes to the login flow instead. No-op when the deadline is unknown.
// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed.
func (t *Tray) openSessionExtendFlow() {
if t.svc.WindowManager == nil {
return
}
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
@@ -308,14 +265,6 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
return
}
if t.svc.WindowManager == nil {
return
}
t.svc.WindowManager.OpenSessionExpiration(seconds)

View File

@@ -145,7 +145,6 @@ type AuthConfig struct {
CLIRedirectURIs []string `yaml:"cliRedirectURIs"`
Owner *AuthOwnerConfig `yaml:"owner,omitempty"`
DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"`
GrantTypes []string `yaml:"grantTypes"`
}
// AuthStorageConfig contains auth storage settings
@@ -605,7 +604,6 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs,
CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs,
DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs,
GrantTypes: mgmt.Auth.GrantTypes,
}
if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" {

View File

@@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
}
hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret))
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
relayCfg := relayServer.Config{
Meter: s.metricsServer.Meter,

View File

@@ -66,9 +66,6 @@
<key>disableAutoConnect</key>
<false/>
<key>disableAutostart</key>
<false/>
<key>disableClientRoutes</key>
<false/>

View File

@@ -103,8 +103,6 @@
<!--
<key>disableAutoConnect</key>
<false/>
<key>disableAutostart</key>
<false/>
<key>disableClientRoutes</key>
<false/>
<key>disableServerRoutes</key>

View File

@@ -58,7 +58,6 @@ preSharedKey="$NULL" # secret; redacted in log
allowServerSSH='true'
blockInbound="$NULL"
disableAutoConnect="$NULL"
disableAutostart="$NULL"
disableClientRoutes="$NULL"
disableServerRoutes="$NULL"
disableMetricsCollection="$NULL"
@@ -156,7 +155,6 @@ main() {
is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart"
is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"

Binary file not shown.

View File

@@ -24,9 +24,6 @@
<string id="DisableAutoConnect_Name">Disable auto-connect</string>
<string id="DisableAutoConnect_Help">When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.</string>
<string id="DisableAutostart_Name">Disable autostart</string>
<string id="DisableAutostart_Help">When enabled, the NetBird GUI is prevented from registering itself as an OS autostart entry on fresh installs, and any existing OS autostart entry registration is removed on the next GUI launch (Windows Registry Run key, macOS Login Item, Linux .desktop). Once the admin lifts the policy, the setting stays off until the user re-enables it in Settings.</string>
<string id="DisableClientRoutes_Name">Disable client routes</string>
<string id="DisableClientRoutes_Help">When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.</string>

View File

@@ -64,18 +64,6 @@
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableAutostart"
class="Machine"
displayName="$(string.DisableAutostart_Name)"
explainText="$(string.DisableAutostart_Help)"
key="Software\Policies\NetBird"
valueName="DisableAutostart">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableClientRoutes"
class="Machine"
displayName="$(string.DisableClientRoutes_Name)"

View File

@@ -17,8 +17,7 @@ RUN apk add --no-cache bash ca-certificates ip6tables iproute2 iptables
ENV NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
NB_ENABLE_CAPTURE="false"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --from=builder /out/netbird /usr/local/bin/netbird

5
go.mod
View File

@@ -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
@@ -334,7 +335,7 @@ replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801
replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1

10
go.sum
View File

@@ -476,8 +476,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0=
github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg=
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88=
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M=
github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus=
@@ -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=

View File

@@ -613,10 +613,6 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s
cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes
}
if len(c.OAuth2.GrantTypes) > 0 {
cfg.AllowedGrantTypes = c.OAuth2.GrantTypes
}
// Apply expiry settings
if c.Expiry.IDTokens != "" {
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {

View File

@@ -21,7 +21,7 @@ import (
"github.com/dexidp/dex/server/signer"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
"github.com/go-jose/go-jose/v4"
jose "github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/bcrypt"

View File

@@ -595,90 +595,3 @@ enablePasswordDB: true
assert.True(t, cfg.ContinueOnConnectorFailure,
"buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down")
}
func TestToServerConfig_WiresGrantTypes(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dex-grants-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
stor := openTestStorage(t, tmpDir)
defer stor.Close()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
grants := []string{"authorization_code", "refresh_token"}
cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}}
assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes)
empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"}
assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes)
}
func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider {
t.Helper()
tmpDir, err := os.MkdirTemp("", "dex-devguard-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
yamlContent := `
issuer: http://localhost:5599/oauth2
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5599
enablePasswordDB: true
` + grantTypesYAML
configPath := filepath.Join(tmpDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644))
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
provider, err := NewProviderFromYAML(context.Background(), yamlConfig)
require.NoError(t, err)
t.Cleanup(func() { _ = provider.Stop(context.Background()) })
return provider
}
func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) {
provider := newDeviceGuardProvider(t, `
oauth2:
grantTypes:
- authorization_code
- refresh_token
`)
devicePaths := []string{
"/oauth2/device",
"/oauth2/device/code",
"/oauth2/device/token",
"/oauth2/device/auth/verify_code",
"/oauth2/device/callback",
}
for _, path := range devicePaths {
for _, method := range []string{http.MethodGet, http.MethodPost} {
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path)
}
}
req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) {
provider := newDeviceGuardProvider(t, "")
req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.NotEqual(t, http.StatusNotFound, rec.Code)
}

View File

@@ -197,12 +197,6 @@ type JSONMetadataInjection struct {
// enforces a 128-char limit per value; oversized values are
// truncated rather than failing the request. 0 disables the cap.
MaxValueLength int
// Sanitize, when true, replaces characters outside the destination's
// accepted set with '_' before emitting each value. AWS Bedrock's
// X-Amzn-Bedrock-Request-Metadata restricts values to a limited character
// class, so unsanitized group display names (e.g. containing spaces) would
// make Bedrock reject the request with 400.
Sanitize bool
}
// providers is the canonical list of supported Agent Network providers.
@@ -335,18 +329,6 @@ var providers = []Provider{
{ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000},
{ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000},
},
// Bedrock accepts a cost-allocation metadata header; stamp the caller's
// user + authorizing group so spend can be attributed in AWS Cost
// Management. Sanitized because Bedrock restricts the value character set.
IdentityInjection: &IdentityInjection{
JSONMetadata: &JSONMetadataInjection{
Header: "X-Amzn-Bedrock-Request-Metadata",
UserKey: "user",
GroupsKey: "group",
MaxValueLength: 256,
Sanitize: true,
},
},
},
{
ID: "vertex_ai_api",

View File

@@ -540,7 +540,6 @@ type identityInjectJSONMetadata struct {
UserKey string `json:"user_key,omitempty"`
GroupsKey string `json:"groups_key,omitempty"`
MaxValueLength int `json:"max_value_length,omitempty"`
Sanitize bool `json:"sanitize,omitempty"`
}
// buildIdentityInjectConfigJSON walks the enabled providers and emits
@@ -584,11 +583,9 @@ func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[s
func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) {
rule := identityInjectProvider{ProviderID: p.ID}
// Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the
// shape silently when the catalog entry doesn't declare one, or when the
// operator disabled metadata for this provider — extras can still apply,
// see below. MetadataDisabled suppresses only the identity dimensions
// (user + authorizing group), not the catalog's routing ExtraHeaders.
if !p.MetadataDisabled && entry.IdentityInjection != nil {
// shape silently when the catalog entry doesn't declare one — extras
// can still apply, see below.
if entry.IdentityInjection != nil {
switch {
case entry.IdentityInjection.HeaderPair != nil:
rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair)
@@ -654,7 +651,6 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject
UserKey: userKey,
GroupsKey: groupsKey,
MaxValueLength: jm.MaxValueLength,
Sanitize: jm.Sanitize,
}
}

View File

@@ -698,94 +698,6 @@ func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T)
"same fixed-schema guarantee for the groups dimension")
}
// TestSynthesizeServices_IdentityInject_Bedrock pins Bedrock's cost-allocation
// metadata: a JSONMetadata shape emitting X-Amzn-Bedrock-Request-Metadata with
// the reserved user/group keys, sanitized to Bedrock's accepted charset.
func TestSynthesizeServices_IdentityInject_Bedrock(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockStore(ctrl)
br := newSynthTestProvider()
br.ID = "prov-bedrock"
br.ProviderID = "bedrock_api"
br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com"
br.APIKey = "bedrock-bearer"
br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
policy := newSynthTestPolicy(br.ID, "grp-eng", "")
policy.ID = "pol-bedrock"
expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(),
[]*types.Provider{br},
[]*types.Policy{policy},
[]*types.Guardrail{})
services, err := SynthesizeServices(ctx, mockStore, testAccountID)
require.NoError(t, err)
require.Len(t, services, 1)
var injectCfg identityInjectConfig
for _, m := range services[0].Targets[0].Options.Middlewares {
if m.ID == middlewareIDLLMIdentityInject {
require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg))
break
}
}
require.Len(t, injectCfg.Providers, 1)
entry := injectCfg.Providers[0]
require.NotNil(t, entry.JSONMetadata, "Bedrock uses the JSONMetadata shape for cost-allocation metadata")
assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive")
assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", entry.JSONMetadata.Header,
"the caller identity lands in Bedrock's cost-allocation metadata header")
assert.Equal(t, "user", entry.JSONMetadata.UserKey)
assert.Equal(t, "group", entry.JSONMetadata.GroupsKey)
assert.True(t, entry.JSONMetadata.Sanitize,
"Bedrock restricts the metadata value charset, so values must be sanitized")
}
// TestSynthesizeServices_MetadataDisabled_SuppressesInjection verifies the
// per-provider opt-out: a provider with MetadataDisabled set emits no
// identity-inject entry (Bedrock has no catalog ExtraHeaders, so the whole
// entry is dropped).
func TestSynthesizeServices_MetadataDisabled_SuppressesInjection(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockStore(ctrl)
br := newSynthTestProvider()
br.ID = "prov-bedrock"
br.ProviderID = "bedrock_api"
br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com"
br.APIKey = "bedrock-bearer"
br.MetadataDisabled = true
br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
policy := newSynthTestPolicy(br.ID, "grp-eng", "")
policy.ID = "pol-bedrock"
expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(),
[]*types.Provider{br},
[]*types.Policy{policy},
[]*types.Guardrail{})
services, err := SynthesizeServices(ctx, mockStore, testAccountID)
require.NoError(t, err)
require.Len(t, services, 1)
var injectCfg identityInjectConfig
for _, m := range services[0].Targets[0].Options.Middlewares {
if m.ID == middlewareIDLLMIdentityInject {
require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg))
break
}
}
assert.Empty(t, injectCfg.Providers,
"metadata_disabled must drop the provider's identity-inject entry")
}
// TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI
// Gateway's wiring: HeaderPair shape with fixed wire names dictated
// by Vercel's Custom Reporting API (ai-reporting-user /

View File

@@ -51,12 +51,6 @@ type Provider struct {
// private or self-signed certificate. The synthesiser propagates it into
// the router route so the proxy dials that provider's upstream insecurely.
SkipTLSVerification bool `gorm:"column:skip_tls_verification"`
// MetadataDisabled suppresses identity metadata injection for this provider.
// Metadata (the caller's user + authorizing group) is injected by default;
// when true the synthesiser omits the provider's identity-inject shape, so no
// user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are
// stamped. Catalog ExtraHeaders (routing config) are unaffected.
MetadataDisabled bool `gorm:"column:metadata_disabled"`
// SessionPrivateKey + SessionPublicKey are the ed25519 keypair the
// synthesised reverse-proxy service uses to sign / verify session
// JWTs after a successful OIDC handshake. Generated once on
@@ -143,9 +137,6 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
if req.SkipTlsVerification != nil {
p.SkipTLSVerification = *req.SkipTlsVerification
}
if req.MetadataDisabled != nil {
p.MetadataDisabled = *req.MetadataDisabled
}
// Identity-header overrides for catalogs flagged Customizable.
// nil pointer = "field omitted on the wire" → leave the stored
// value untouched (per the openapi description). Empty string is
@@ -179,7 +170,6 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
Models: models,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
}

View File

@@ -42,38 +42,3 @@ func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) {
assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification")
assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value")
}
// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response
// mapping of metadata_disabled, with the same update semantics: nil preserves,
// explicit false clears.
func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
enable := true
disable := false
base := func() *api.AgentNetworkProviderRequest {
return &api.AgentNetworkProviderRequest{
ProviderId: "bedrock_api",
Name: "bedrock",
UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
}
}
p := NewProvider("acc-1")
req := base()
req.MetadataDisabled = &enable
p.FromAPIRequest(req)
assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field")
assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled")
// Omitting the field on update leaves the stored value untouched.
p.FromAPIRequest(base())
assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it")
// Explicit false clears it (re-enables metadata).
req = base()
req.MetadataDisabled = &disable
p.FromAPIRequest(req)
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
}

View File

@@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
}
// Sanity: an actually different range still triggers reallocation.
newRange := netip.MustParsePrefix("100.60.0.0/16")
newRange := netip.MustParsePrefix("100.99.0.0/16")
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: types.DefaultPeerLoginExpiration,

View File

@@ -76,9 +76,6 @@ type EmbeddedIdPConfig struct {
DashboardPostLogoutRedirectURIs []string
// StaticConnectors are additional connectors to seed during initialization
StaticConnectors []dex.Connector
// GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the
// device_code grant to disable the device flow; keep authorization_code and refresh_token.
GrantTypes []string
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
@@ -178,7 +175,6 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
},
OAuth2: dex.OAuth2{
SkipApprovalScreen: true,
GrantTypes: c.GrantTypes,
},
Frontend: dex.Frontend{
Issuer: "NetBird",

View File

@@ -1606,8 +1606,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled,
settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only,
settings_dashboard_features, settings_auto_update_version, settings_auto_update_always,
settings_peer_expose_enabled, settings_peer_expose_groups,
settings_dashboard_features,
-- Embedded ExtraSettings
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
@@ -1633,10 +1632,6 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sMetricsPushEnabled sql.NullBool
sAgentNetworkOnly sql.NullBool
sDashboardFeatures sql.NullString
autoUpdateVersion sql.NullString
autoUpdateAlways sql.NullBool
peerExposeEnabled sql.NullBool
peerExposeGroups sql.NullString
sExtraPeerApprovalEnabled sql.NullBool
sExtraUserApprovalRequired sql.NullBool
sExtraIntegratedValidator sql.NullString
@@ -1660,8 +1655,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
&sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways,
&peerExposeEnabled, &peerExposeGroups,
&sDashboardFeatures,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
)
@@ -1753,18 +1747,6 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
if sIPv6EnabledGroups.Valid {
_ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups)
}
if autoUpdateAlways.Valid {
account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool
}
if autoUpdateVersion.Valid {
account.Settings.AutoUpdateVersion = autoUpdateVersion.String
}
if peerExposeEnabled.Valid {
account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool
}
if peerExposeGroups.Valid {
_ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups)
}
if sExtraPeerApprovalEnabled.Valid {
account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool

View File

@@ -9,7 +9,6 @@ import (
"net"
"net/netip"
"os"
"reflect"
"runtime"
"sort"
"sync"
@@ -35,7 +34,6 @@ import (
"github.com/netbirdio/netbird/management/server/util"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/shared/testing_helpers"
"github.com/netbirdio/netbird/util/crypt"
)
@@ -298,53 +296,6 @@ func Test_SaveAccount(t *testing.T) {
})
}
func Test_AccountSettings_SaveAndRetrieve(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
}
populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter(
reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) {
es := types.ExtraSettings{}
reflectedEs := reflect.ValueOf(&es).Elem()
n, err := this.PopulateAll(reflectedEs)
if err != nil {
return n, err
}
field.Set(reflectedEs.Addr())
return n, nil
}).WithCustomFieldSetter(
reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) {
t := true
df := types.DashboardFeatures{AgentNetwork: &t}
reflectedDf := reflect.ValueOf(&df).Elem()
field.Set(reflectedDf.Addr())
return 1, nil
}).WithSkippedTag("gorm", "-")
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
account := newAccountWithId(context.Background(), "account_id", "testuser", "")
setupKey, _ := types.GenerateDefaultSetupKey()
account.SetupKeys[setupKey.Key] = setupKey
settings := types.Settings{}
numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem())
assert.NoError(t, err)
assert.Equal(t, 27, numOfExportedFields)
account.Settings = &settings
err = store.SaveAccount(context.Background(), account)
assert.NoError(t, err)
accountFromDb, err := store.GetAccount(context.Background(), account.Id)
assert.NoError(t, err)
assert.NotNil(t, accountFromDb)
assert.NotNil(t, accountFromDb.Settings)
assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match")
})
}
func TestSqlite_DeleteAccount(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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, "")
}

View File

@@ -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)
}
}

View File

@@ -64,11 +64,6 @@ type JSONMetadataRule struct {
UserKey string `json:"user_key,omitempty"`
GroupsKey string `json:"groups_key,omitempty"`
MaxValueLength int `json:"max_value_length,omitempty"`
// Sanitize replaces characters outside the destination provider's accepted
// set with '_' before emitting each value. AWS Bedrock's
// X-Amzn-Bedrock-Request-Metadata restricts values to [A-Za-z0-9 +-=._:/@];
// group display names with other characters would otherwise 400.
Sanitize bool `json:"sanitize,omitempty"`
}
// Config is the on-wire configuration accepted by the factory. An

View File

@@ -292,21 +292,15 @@ func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware
mutations := &middleware.Mutations{}
mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header)
emit := func(v string) string {
if rule.Sanitize {
v = sanitizeMetadataValue(v)
}
return truncate(v, rule.MaxValueLength)
}
payload := map[string]string{}
if rule.UserKey != "" {
if identity := identityFor(in); identity != "" {
payload[rule.UserKey] = emit(identity)
payload[rule.UserKey] = truncate(identity, rule.MaxValueLength)
}
}
if rule.GroupsKey != "" {
if csv := authorisingTagsCSV(in); csv != "" {
payload[rule.GroupsKey] = emit(csv)
payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength)
}
}
if len(payload) == 0 {
@@ -365,36 +359,6 @@ func truncate(s string, maxBytes int) string {
return s[:maxBytes]
}
// sanitizeMetadataValue replaces any character outside AWS Bedrock's accepted
// request-metadata class — letters, digits, space, and + - = . _ : / @ — with
// '_'. This keeps values (notably the groups CSV, whose commas are rejected, and
// group display names with arbitrary characters) from making Bedrock reject the
// request with 400. The result stays opaque to the gateway.
func sanitizeMetadataValue(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if metadataCharAllowed(r) {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
func metadataCharAllowed(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return true
}
switch r {
case ' ', '+', '-', '=', '.', '_', ':', '/', '@':
return true
}
return false
}
// tagsIDsFromAuthorising reads llm_router's authorising-groups metadata
// (a CSV of group ids) and returns the parsed slice. Returns nil when
// the key is absent or empty so the caller can fall back to the full

View File

@@ -304,46 +304,6 @@ func TestInject_JSONMetadata_TruncatesValues(t *testing.T) {
"per-value byte length must be capped at MaxValueLength")
}
// TestInject_JSONMetadata_Sanitize pins the AWS-Bedrock sanitization path: when
// Sanitize is set, characters outside Bedrock's accepted metadata class
// (notably the groups CSV comma and arbitrary characters in group display
// names) are replaced with '_' so Bedrock doesn't reject the request. Allowed
// characters (letters, digits, spaces, and @ . _ : / + - =) pass through.
func TestInject_JSONMetadata_Sanitize(t *testing.T) {
rule := ProviderInjection{
ProviderID: portkeyProvider,
JSONMetadata: &JSONMetadataRule{
Header: "X-Amzn-Bedrock-Request-Metadata",
UserKey: "user",
GroupsKey: "group",
MaxValueLength: 256,
Sanitize: true,
},
}
mw := New(Config{Providers: []ProviderInjection{rule}})
in := newInput(portkeyProvider, "alice", []string{"g1", "g2"})
in.UserEmail = "alice@example.com"
// Group display names carry characters Bedrock rejects (comma, '#'); the CSV
// join adds another comma between the two groups.
in.UserGroupNames = []string{"Eng,Team", "Ops#1"}
out, err := mw.Invoke(context.Background(), in)
require.NoError(t, err)
require.NotNil(t, out.Mutations)
require.Len(t, out.Mutations.HeadersAdd, 1)
added := out.Mutations.HeadersAdd[0]
assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", added.Key,
"the Bedrock cost-allocation header carries the metadata JSON")
var payload map[string]string
require.NoError(t, json.Unmarshal([]byte(added.Value), &payload))
assert.Equal(t, "alice@example.com", payload["user"],
"'@' and '.' are in Bedrock's accepted set and must be preserved")
assert.NotContains(t, payload["group"], ",", "commas must be sanitized — Bedrock rejects them")
assert.NotContains(t, payload["group"], "#", "disallowed characters must be sanitized")
assert.Contains(t, payload["group"], "Eng", "allowed characters must be preserved")
}
// TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the
// anti-spoof Remove still fires when there's nothing to stamp.
func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) {

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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}

View 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
}

Some files were not shown because too many files have changed in this diff Show More