mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
Compare commits
54 Commits
embedded-v
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a46353ae2 | ||
|
|
d663da8f82 | ||
|
|
82fdfa84b8 | ||
|
|
ca80e49aa0 | ||
|
|
51f17bf919 | ||
|
|
724c6a06e6 | ||
|
|
d64e9542eb | ||
|
|
3fb26d458e | ||
|
|
a411fd300c | ||
|
|
92a5ed19d3 | ||
|
|
be6777427d | ||
|
|
a1c9427d80 | ||
|
|
a59d7fba95 | ||
|
|
41d7bf4bbd | ||
|
|
b7b0d5796e | ||
|
|
21fc5b81f6 | ||
|
|
9906b9b1a1 | ||
|
|
3f8c447378 | ||
|
|
6e3f4d8722 | ||
|
|
877e889250 | ||
|
|
099ae4bc6c | ||
|
|
63d60ba490 | ||
|
|
d15830a2d0 | ||
|
|
141f3d0390 | ||
|
|
e1a24376ab | ||
|
|
62fc8d254e | ||
|
|
3a2f773d65 | ||
|
|
8f901f8899 | ||
|
|
c6bf5fbbfb | ||
|
|
f0eed7564f | ||
|
|
277d8e4c53 | ||
|
|
e70a69bbcf | ||
|
|
a48618c074 | ||
|
|
39193396f5 | ||
|
|
5343402385 | ||
|
|
62703ca23e | ||
|
|
cc64a93953 | ||
|
|
831325d6e2 | ||
|
|
8f64173574 | ||
|
|
76877e83c4 | ||
|
|
ecd398d895 | ||
|
|
3d87547d95 | ||
|
|
4d4cc551fd | ||
|
|
08e46aa62f | ||
|
|
7cd5c1732b | ||
|
|
816d80602f | ||
|
|
cf101c44b4 | ||
|
|
79b51a79e4 | ||
|
|
a737504ec9 | ||
|
|
3b1beb3497 | ||
|
|
49c0aeb6ce | ||
|
|
6774a43eae | ||
|
|
50a29c07ce | ||
|
|
7d8e20030b |
@@ -247,6 +247,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
@@ -331,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||
}
|
||||
|
||||
// Mirror runInForegroundMode: recover residual state (DNS, firewall,
|
||||
// ssh config, legacy routing) from a previous unclean shutdown and
|
||||
// enable advanced routing before dialing management.
|
||||
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil {
|
||||
log.Warnf("failed to restore residual state: %v", err)
|
||||
}
|
||||
nbnet.Init()
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
||||
|
||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||
|
||||
// Restore residual state left by a previous run that did not shut down
|
||||
// cleanly, mirroring what the daemon does before connecting: it recovers
|
||||
// DNS config (a stale resolv.conf takeover can make the management
|
||||
// hostname unresolvable), firewall rules, ssh config and legacy routing.
|
||||
// Route cleanup itself happens at engine start; nbnet.Init() below lets
|
||||
// the management dial bypass a leftover fwmark rule until then.
|
||||
// Foreground mode is particularly exposed in containers: a crashed
|
||||
// container restarts inside the same (pod) network namespace, so stale
|
||||
// state survives while the process does not.
|
||||
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil {
|
||||
log.Warnf("failed to restore residual state: %v", err)
|
||||
}
|
||||
|
||||
// Enable advanced routing (as the daemon does on startup) so the
|
||||
// management dial bypasses a leftover fwmark rule instead of being
|
||||
// shunted into a stale routing table.
|
||||
nbnet.Init()
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
|
||||
@@ -121,6 +121,7 @@ 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
|
||||
@@ -183,6 +184,41 @@ 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)
|
||||
@@ -286,6 +322,8 @@ 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 {
|
||||
@@ -299,6 +337,7 @@ 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
|
||||
@@ -694,6 +733,10 @@ 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()
|
||||
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO: pass fragments of routed packets to forwarder
|
||||
// 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.
|
||||
if fragment {
|
||||
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.filterInboundFragment(d, srcIP, dstIP, size)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
||||
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 {
|
||||
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),
|
||||
})
|
||||
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
|
||||
|
||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
||||
if !pass {
|
||||
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),
|
||||
})
|
||||
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,11 @@ 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 {
|
||||
@@ -152,6 +159,8 @@ 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)
|
||||
|
||||
@@ -466,3 +475,31 @@ 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")
|
||||
}
|
||||
|
||||
204
client/firewall/uspfilter/fragment.go
Normal file
204
client/firewall/uspfilter/fragment.go
Normal file
@@ -0,0 +1,204 @@
|
||||
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()
|
||||
}
|
||||
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
554
client/firewall/uspfilter/fragment_test.go
Normal file
554
client/firewall/uspfilter/fragment_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
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")
|
||||
}
|
||||
@@ -3,14 +3,31 @@
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||
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"
|
||||
)
|
||||
|
||||
// IsEnabled todo: move these function to cmd layer
|
||||
func IsEnabled() bool {
|
||||
@@ -18,24 +35,40 @@ func IsEnabled() bool {
|
||||
}
|
||||
|
||||
func ListenAddr() string {
|
||||
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
|
||||
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)
|
||||
if sPort == "" {
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
return 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 listenAddr(DefaultSocks5Port)
|
||||
return 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 listenAddr(DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
}
|
||||
|
||||
return listenAddr(port)
|
||||
}
|
||||
|
||||
func listenAddr(port int) string {
|
||||
return fmt.Sprintf("0.0.0.0:%d", port)
|
||||
return port
|
||||
}
|
||||
|
||||
63
client/iface/netstack/env_test.go
Normal file
63
client/iface/netstack/env_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
//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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
||||
UseIDToken: d.providerConfig.UseIDToken,
|
||||
}
|
||||
|
||||
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
if err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
audience = p.providerConfig.ClientID
|
||||
}
|
||||
|
||||
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||
}
|
||||
|
||||
@@ -320,6 +320,11 @@ 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 {
|
||||
|
||||
@@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) {
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// isValidAccessToken is a simple validation of the access token
|
||||
func isValidAccessToken(token string, audience string) error {
|
||||
// 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 {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token received is empty")
|
||||
}
|
||||
|
||||
encodedClaims := strings.Split(token, ".")[1]
|
||||
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
||||
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])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
108
client/internal/auth/util_test.go
Normal file
108
client/internal/auth/util_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,6 @@ func (g *BundleGenerator) addStatus() error {
|
||||
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
|
||||
@@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
||||
return customPort, nil
|
||||
}
|
||||
|
||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
||||
err = probeListener.Close()
|
||||
if err != nil {
|
||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||
if err = probeListener.Close(); err != nil {
|
||||
log.Debugf("failed to free up DNS port: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return addrPort.Port(), nil
|
||||
return port, nil
|
||||
}
|
||||
|
||||
@@ -2605,13 +2605,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
|
||||
|
||||
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
||||
excludedPeers := make(map[string]bool)
|
||||
|
||||
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
|
||||
// cannot wake a lazy connection, so the peer routing the target must stay
|
||||
// permanently connected. AllowedIPs are already parsed on the peer conn, so
|
||||
// reuse those typed prefixes instead of re-parsing the network map strings.
|
||||
for _, r := range rules {
|
||||
ip := r.TranslatedAddress
|
||||
for _, p := range peers {
|
||||
for _, allowedIP := range p.GetAllowedIps() {
|
||||
if allowedIP != ip.String() {
|
||||
continue
|
||||
}
|
||||
if e.peerRoutesAddr(p, r.TranslatedAddress) {
|
||||
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
|
||||
excludedPeers[p.GetWgPubKey()] = true
|
||||
}
|
||||
@@ -2621,6 +2622,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers
|
||||
return excludedPeers
|
||||
}
|
||||
|
||||
// peerRoutesAddr reports whether the peer is a router for addr, matched against
|
||||
// the peer's already-parsed AllowedIPs from the store (the same typed value the
|
||||
// lazy manager consumes) rather than re-parsing the network map strings.
|
||||
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
|
||||
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return prefixesContain(prefixes, addr)
|
||||
}
|
||||
|
||||
// prefixesContain reports whether addr falls within any of the prefixes.
|
||||
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isChecksEqual checks if two slices of checks are equal.
|
||||
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||
normalize := func(checks []*mgmProto.Checks) []string {
|
||||
|
||||
87
client/internal/engine_lazy_exclude_test.go
Normal file
87
client/internal/engine_lazy_exclude_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestPrefixesContain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefixes []string
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
|
||||
{name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
|
||||
{name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
|
||||
{name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
|
||||
{name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
|
||||
{name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
|
||||
for _, p := range tt.prefixes {
|
||||
prefixes = append(prefixes, netip.MustParsePrefix(p))
|
||||
}
|
||||
require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
|
||||
// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
|
||||
// lazy connections, matched via the peer's already-parsed AllowedIPs.
|
||||
func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
|
||||
const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
|
||||
const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
|
||||
|
||||
store := peerstore.NewConnStore()
|
||||
store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
|
||||
store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
|
||||
|
||||
e := &Engine{peerStore: store}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
|
||||
{WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
|
||||
}
|
||||
rules := []firewallManager.ForwardRule{
|
||||
{TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
|
||||
}
|
||||
|
||||
excluded := e.toExcludedLazyPeers(rules, peers)
|
||||
|
||||
require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
|
||||
require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
|
||||
require.Len(t, excluded, 1)
|
||||
}
|
||||
|
||||
func TestToExcludedLazyPeers_NoRules(t *testing.T) {
|
||||
e := &Engine{peerStore: peerstore.NewConnStore()}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
|
||||
}
|
||||
|
||||
require.Empty(t, e.toExcludedLazyPeers(nil, peers))
|
||||
}
|
||||
|
||||
func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
@@ -203,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
||||
statusICE: worker.NewAtomicStatus(),
|
||||
dumpState: dumpState,
|
||||
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
|
||||
wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
|
||||
metricsRecorder: services.MetricsRecorder,
|
||||
}
|
||||
|
||||
@@ -671,11 +670,12 @@ func (conn *Conn) onGuardEvent() {
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) onWGDisconnected() {
|
||||
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
if conn.ctx.Err() != nil {
|
||||
// watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
|
||||
if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -833,25 +833,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
||||
})
|
||||
}
|
||||
|
||||
// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its
|
||||
// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown.
|
||||
// Caller must hold conn.mu.
|
||||
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
||||
if !conn.wgWatcher.PrepareInitialHandshake() {
|
||||
if conn.wgWatcher != nil {
|
||||
return
|
||||
}
|
||||
|
||||
watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState)
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
||||
conn.wgWatcher = watcher
|
||||
conn.wgWatcherCancel = wgWatcherCancel
|
||||
|
||||
conn.wgWatcherWg.Add(1)
|
||||
go func() {
|
||||
defer conn.wgWatcherWg.Done()
|
||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
|
||||
onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
|
||||
watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
|
||||
}()
|
||||
}
|
||||
|
||||
// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never
|
||||
// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so
|
||||
// blocking would deadlock. Caller must hold conn.mu.
|
||||
func (conn *Conn) disableWgWatcherIfNeeded() {
|
||||
if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcherCancel = nil
|
||||
if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil {
|
||||
return
|
||||
}
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcher = nil
|
||||
conn.wgWatcherCancel = nil
|
||||
}
|
||||
|
||||
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||
@@ -874,7 +888,9 @@ func (conn *Conn) resetEndpoint() {
|
||||
return
|
||||
}
|
||||
conn.Log.Infof("reset wg endpoint")
|
||||
conn.wgWatcher.Reset()
|
||||
if conn.wgWatcher != nil {
|
||||
conn.wgWatcher.Reset()
|
||||
}
|
||||
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
|
||||
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
|
||||
}
|
||||
|
||||
@@ -339,20 +339,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
|
||||
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
|
||||
"reaching the threshold must report the peer disconnected once")
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
|
||||
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
|
||||
}
|
||||
|
||||
@@ -364,12 +364,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
conn.onWGCheckSuccess()
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(false, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
|
||||
conn.onWGDisconnected()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -24,14 +23,14 @@ type WGInterfaceStater interface {
|
||||
GetStats() (map[string]configurer.WGStats, error)
|
||||
}
|
||||
|
||||
// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded.
|
||||
// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale.
|
||||
type WGWatcher struct {
|
||||
log *log.Entry
|
||||
wgIfaceStater WGInterfaceStater
|
||||
peerKey string
|
||||
stateDump *stateDump
|
||||
|
||||
enabled bool
|
||||
muEnabled sync.Mutex
|
||||
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
||||
initialHandshake time.Time
|
||||
|
||||
@@ -48,25 +47,14 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
|
||||
// handshake time. It must be called before the peer is (re)configured on the WireGuard
|
||||
// interface, so the captured baseline reflects the state prior to this connection attempt
|
||||
// instead of racing with that configuration. Returns ok=false if the watcher is already
|
||||
// running, in which case EnableWgWatcher must not be called.
|
||||
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
||||
w.muEnabled.Lock()
|
||||
if w.enabled {
|
||||
w.muEnabled.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
|
||||
// called before the peer is (re)configured on the WireGuard interface, so the captured
|
||||
// baseline reflects the state prior to this connection attempt instead of racing with
|
||||
// that configuration.
|
||||
func (w *WGWatcher) PrepareInitialHandshake() {
|
||||
w.log.Debugf("enable WireGuard watcher")
|
||||
w.enabled = true
|
||||
w.muEnabled.Unlock()
|
||||
|
||||
handshake, _ := w.wgState()
|
||||
w.initialHandshake = handshake
|
||||
return true
|
||||
}
|
||||
|
||||
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
|
||||
@@ -76,10 +64,6 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
||||
// handshake, including the first.
|
||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
|
||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
|
||||
|
||||
w.muEnabled.Lock()
|
||||
w.enabled = false
|
||||
w.muEnabled.Unlock()
|
||||
}
|
||||
|
||||
// Reset signals the watcher that the WireGuard peer has been reset and a new
|
||||
@@ -105,6 +89,7 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
|
||||
case <-timer.C:
|
||||
handshake, ok := w.handshakeCheck(lastHandshake)
|
||||
if !ok {
|
||||
// early ctx cancel check return
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -153,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) {
|
||||
|
||||
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
|
||||
|
||||
// the current know handshake did not change
|
||||
// the current known handshake did not change
|
||||
if handshake.Equal(lastHandshake) {
|
||||
w.log.Warnf("WireGuard handshake timed out: %v", handshake)
|
||||
w.log.Warnf("WireGuard handshake not updated: %v", handshake)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
)
|
||||
@@ -62,7 +61,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
require.True(t, watcher.PrepareInitialHandshake())
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
firstHandshake := make(chan struct{}, 1)
|
||||
checkSuccess := make(chan struct{}, 1)
|
||||
@@ -101,8 +100,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ok := watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should not be enabled yet")
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
@@ -132,8 +130,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ok := watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should not be enabled yet")
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
@@ -149,8 +146,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ok = watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
|
||||
@@ -54,19 +54,15 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.relaySupportedOnRemotePeer.Store(true)
|
||||
|
||||
// the relayManager will return with error in case if the connection has lost with relay server
|
||||
currentRelayAddress, _, err := w.relayManager.RelayInstanceAddress()
|
||||
_, _, err := w.relayManager.RelayInstanceAddress()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to handle new offer: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
srv := w.preferredRelayServer(currentRelayAddress, remoteOfferAnswer.RelaySrvAddress)
|
||||
var serverIP netip.Addr
|
||||
if srv == remoteOfferAnswer.RelaySrvAddress {
|
||||
serverIP = remoteOfferAnswer.RelaySrvIP
|
||||
}
|
||||
|
||||
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP)
|
||||
preferForeign := !w.isController
|
||||
remoteRelayServer := relayClient.RelayServer{Addr: remoteOfferAnswer.RelaySrvAddress, IP: remoteOfferAnswer.RelaySrvIP}
|
||||
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, remoteRelayServer, w.config.Key, preferForeign)
|
||||
if err != nil {
|
||||
if errors.Is(err, relayClient.ErrConnAlreadyExists) {
|
||||
w.log.Debugf("handled offer by reusing existing relay connection")
|
||||
@@ -80,14 +76,13 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.relayedConn = relayedConn
|
||||
w.relayLock.Unlock()
|
||||
|
||||
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add close listener: %s", err)
|
||||
if err := w.relayManager.AddCloseListener(relayedConn.RemoteAddr().String(), w.onRelayClientDisconnected); err != nil {
|
||||
w.log.Errorf("failed to add close listener: %s", err)
|
||||
_ = relayedConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
w.log.Debugf("peer conn opened via Relay: %s", srv)
|
||||
w.log.Debugf("peer conn opened via Relay: %s", relayedConn.RemoteAddr())
|
||||
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
|
||||
relayedConn: relayedConn,
|
||||
rosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
|
||||
@@ -126,13 +121,6 @@ func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool {
|
||||
return answer.RelaySrvAddress != ""
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress string) string {
|
||||
if w.isController {
|
||||
return myRelayAddress
|
||||
}
|
||||
return remoteRelayAddress
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) onRelayClientDisconnected() {
|
||||
go w.conn.onRelayDisconnected()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -305,6 +306,11 @@ 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)
|
||||
}
|
||||
|
||||
@@ -233,6 +233,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -44,10 +44,25 @@ type Auth struct {
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
// Load the existing config when a config file is already present so an
|
||||
// interactive re-login reuses the peer's persisted WireGuard private key
|
||||
// (and thus its identity) instead of generating a fresh one. Generating a
|
||||
// new key registers a brand-new peer on the management server on every
|
||||
// re-auth (named after the fallback hostname). Only fall back to a fresh
|
||||
// in-memory config for the first-time login when no config file exists yet.
|
||||
// DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
|
||||
// the tvOS App Group sandbox where atomic temp-file+rename is blocked.
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
if cfgPath != "" {
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
|
||||
} else {
|
||||
cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ var allKeys = []string{
|
||||
KeyDisableMetricsCollection,
|
||||
KeyAllowServerSSH,
|
||||
KeyDisableAutoConnect,
|
||||
KeyDisableAutostart,
|
||||
KeyPreSharedKey,
|
||||
KeyRosenpassEnabled,
|
||||
KeyRosenpassPermissive,
|
||||
|
||||
@@ -20,10 +20,10 @@ import (
|
||||
// names (lowerCamelCase) so the daemon can map a Policy key directly to a
|
||||
// configuration field.
|
||||
const (
|
||||
KeyManagementURL = "managementURL"
|
||||
KeyDisableUpdateSettings = "disableUpdateSettings"
|
||||
KeyDisableProfiles = "disableProfiles"
|
||||
KeyDisableNetworks = "disableNetworks"
|
||||
KeyManagementURL = "managementURL"
|
||||
KeyDisableUpdateSettings = "disableUpdateSettings"
|
||||
KeyDisableProfiles = "disableProfiles"
|
||||
KeyDisableNetworks = "disableNetworks"
|
||||
// KeyDisableAdvancedView gates the advanced-view section in the
|
||||
// upcoming UI revision. UI-only: NOT stored on Config, not
|
||||
// applied by applyMDMPolicy, not rejectable via SetConfig. The
|
||||
@@ -37,10 +37,16 @@ const (
|
||||
KeyDisableMetricsCollection = "disableMetricsCollection"
|
||||
KeyAllowServerSSH = "allowServerSSH"
|
||||
KeyDisableAutoConnect = "disableAutoConnect"
|
||||
KeyPreSharedKey = "preSharedKey"
|
||||
KeyRosenpassEnabled = "rosenpassEnabled"
|
||||
KeyRosenpassPermissive = "rosenpassPermissive"
|
||||
KeyWireguardPort = "wireguardPort"
|
||||
// KeyDisableAutostart suppresses the GUI's fresh-install
|
||||
// launch-on-login default and marks the Settings toggle as
|
||||
// MDM-managed. UI-only: NOT stored on Config and not applied by
|
||||
// applyMDMPolicy; the GUI reads it directly and it appears in
|
||||
// GetConfigResponse.mDMManagedFields when set.
|
||||
KeyDisableAutostart = "disableAutostart"
|
||||
KeyPreSharedKey = "preSharedKey"
|
||||
KeyRosenpassEnabled = "rosenpassEnabled"
|
||||
KeyRosenpassPermissive = "rosenpassPermissive"
|
||||
KeyWireguardPort = "wireguardPort"
|
||||
|
||||
// Split tunnel is modeled as a single conceptual policy with two
|
||||
// registry/plist values. KeySplitTunnelMode is the discriminator
|
||||
|
||||
@@ -181,7 +181,7 @@ func (s *Server) Start() error {
|
||||
log.Warnf("failed to redirect stderr: %v", err)
|
||||
}
|
||||
|
||||
if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
s.actCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
@@ -858,7 +858,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
|
||||
return s.waitForUp(callerCtx)
|
||||
}
|
||||
if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (
|
||||
|
||||
if req.All {
|
||||
// Reuse existing cleanup logic for all states
|
||||
if err := restoreResidualState(ctx, statePath); err != nil {
|
||||
if err := RestoreResidualState(ctx, statePath); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err)
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
|
||||
// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
|
||||
// Otherwise, we might not be able to connect to the management server to retrieve new config.
|
||||
func restoreResidualState(ctx context.Context, statePath string) error {
|
||||
func RestoreResidualState(ctx context.Context, statePath string) error {
|
||||
if statePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
|
||||
if peer.IPv6.IsValid() {
|
||||
hostPatterns = append(hostPatterns, peer.IPv6.String())
|
||||
}
|
||||
if peer.FQDN != "" {
|
||||
// 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) {
|
||||
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.Hostname != peer.FQDN {
|
||||
if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -148,6 +148,45 @@ 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")
|
||||
|
||||
@@ -69,7 +69,8 @@ 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).
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
|
||||
// rejected so the input can never be parsed as a command-line flag.
|
||||
func validateGetentInput(input string) bool {
|
||||
maxLen := 32
|
||||
if runtime.GOOS == "linux" {
|
||||
@@ -80,6 +81,10 @@ func validateGetentInput(input string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if input[0] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range input {
|
||||
if isAllowedGetentChar(r) {
|
||||
continue
|
||||
|
||||
@@ -157,6 +157,9 @@ 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},
|
||||
|
||||
@@ -746,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
|
||||
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
|
||||
}
|
||||
|
||||
pbFullStatus.Events = fullStatus.Events
|
||||
|
||||
return &pbFullStatus
|
||||
}
|
||||
|
||||
|
||||
107
client/ui/autostart_default.go
Normal file
107
client/ui/autostart_default.go
Normal file
@@ -0,0 +1,107 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
// autostartDefaultState carries the guard inputs of the one-time autostart
|
||||
// default decision so the decision itself stays a pure, testable function.
|
||||
type autostartDefaultState struct {
|
||||
supported bool
|
||||
mdmDisabled bool
|
||||
priorInstall bool
|
||||
}
|
||||
|
||||
// shouldEnableAutostartDefault applies the first-run guards in order and
|
||||
// returns whether autostart may be enabled, plus the reason when it may not.
|
||||
func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) {
|
||||
switch {
|
||||
case !s.supported:
|
||||
return false, "autostart not supported on this platform"
|
||||
case s.mdmDisabled:
|
||||
return false, "autostart disabled by MDM policy"
|
||||
case s.priorInstall:
|
||||
return false, "existing NetBird installation"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// autostartDisabledByMDM reports whether the MDM policy manages the
|
||||
// disableAutostart key in a way that must suppress the default. An
|
||||
// unparseable managed value is treated as disabled to stay on the safe side.
|
||||
func autostartDisabledByMDM(policy *mdm.Policy) bool {
|
||||
if !policy.HasKey(mdm.KeyDisableAutostart) {
|
||||
return false
|
||||
}
|
||||
disabled, ok := policy.GetBool(mdm.KeyDisableAutostart)
|
||||
return !ok || disabled
|
||||
}
|
||||
|
||||
// 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 login-item write.
|
||||
func netbirdFootprintExists() bool {
|
||||
candidates := []string{
|
||||
profilemanager.DefaultConfigPath,
|
||||
filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"),
|
||||
filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if path != "" && fileExists(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
priorFootprint := netbirdFootprintExists() || prefsFileExisted
|
||||
|
||||
if prefs.Get().AutostartInitialized {
|
||||
return
|
||||
}
|
||||
if err := prefs.SetAutostartInitialized(true); err != nil {
|
||||
log.Warnf("persist autostart marker, skipping autostart default: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := autostartDefaultState{
|
||||
supported: autostart.Supported(ctx),
|
||||
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
|
||||
priorInstall: priorFootprint,
|
||||
}
|
||||
enable, reason := shouldEnableAutostartDefault(state)
|
||||
if !enable {
|
||||
log.Debugf("skipping autostart default: %s", reason)
|
||||
return
|
||||
}
|
||||
|
||||
if err := autostart.SetEnabled(ctx, true); err != nil {
|
||||
log.Warnf("enable autostart on fresh install: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("autostart enabled by default on fresh install")
|
||||
}
|
||||
|
||||
// fileExists reports whether path exists.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
125
client/ui/autostart_default_test.go
Normal file
125
client/ui/autostart_default_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
func TestShouldEnableAutostartDefault(t *testing.T) {
|
||||
allPass := autostartDefaultState{
|
||||
supported: true,
|
||||
mdmDisabled: false,
|
||||
priorInstall: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*autostartDefaultState)
|
||||
wantEnable bool
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "fresh install with all guards passing enables",
|
||||
mutate: func(*autostartDefaultState) {},
|
||||
wantEnable: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported platform skips",
|
||||
mutate: func(s *autostartDefaultState) { s.supported = false },
|
||||
wantReason: "autostart not supported on this platform",
|
||||
},
|
||||
{
|
||||
name: "MDM disable skips",
|
||||
mutate: func(s *autostartDefaultState) { s.mdmDisabled = true },
|
||||
wantReason: "autostart disabled by MDM policy",
|
||||
},
|
||||
{
|
||||
name: "existing installation (upgrade) skips",
|
||||
mutate: func(s *autostartDefaultState) { s.priorInstall = true },
|
||||
wantReason: "existing NetBird installation",
|
||||
},
|
||||
{
|
||||
name: "unsupported wins over every other guard",
|
||||
mutate: func(s *autostartDefaultState) {
|
||||
s.supported = false
|
||||
s.mdmDisabled = true
|
||||
s.priorInstall = true
|
||||
},
|
||||
wantReason: "autostart not supported on this platform",
|
||||
},
|
||||
{
|
||||
name: "MDM disable wins over prior install",
|
||||
mutate: func(s *autostartDefaultState) {
|
||||
s.mdmDisabled = true
|
||||
s.priorInstall = true
|
||||
},
|
||||
wantReason: "autostart disabled by MDM policy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
state := allPass
|
||||
tc.mutate(&state)
|
||||
enable, reason := shouldEnableAutostartDefault(state)
|
||||
assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state)
|
||||
assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutostartDisabledByMDM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty policy does not disable",
|
||||
values: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated managed keys do not disable",
|
||||
values: map[string]any{mdm.KeyDisableAutoConnect: true},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "disableAutostart true disables",
|
||||
values: map[string]any{mdm.KeyDisableAutostart: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "disableAutostart registry DWORD 1 disables",
|
||||
values: map[string]any{mdm.KeyDisableAutostart: int64(1)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "disableAutostart string true disables",
|
||||
values: map[string]any{mdm.KeyDisableAutostart: "true"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "disableAutostart explicit false allows",
|
||||
values: map[string]any{mdm.KeyDisableAutostart: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unparseable managed value is treated as disabled",
|
||||
values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := autostartDisabledByMDM(mdm.NewPolicy(tc.values))
|
||||
assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,14 @@ async function runSsoLogin(
|
||||
if (uri) await openBrowserLoginUri(uri);
|
||||
|
||||
const cancelPromise = buildSsoCancelPromise(state, signal);
|
||||
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
|
||||
// Combine wait + up in Go so the connection comes up the moment SSO
|
||||
// completes. During SSO the tray window is hidden and the webview is
|
||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||
{ userCode: result.userCode, hostname: "" },
|
||||
{ profileName: "", username: "" },
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([waitPromise, cancelPromise]);
|
||||
@@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
|
||||
if (signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled && result.needsSsoLogin) {
|
||||
// runSsoLogin brings the connection up in Go once SSO completes.
|
||||
await runSsoLogin(result, state, signal);
|
||||
}
|
||||
|
||||
if (!state.cancelled && signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled) {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
} else {
|
||||
if (!state.cancelled && signal?.aborted) state.cancelled = true;
|
||||
if (!state.cancelled) {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{"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": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
|
||||
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}
|
||||
]
|
||||
}
|
||||
|
||||
1325
client/ui/i18n/locales/ja/common.json
Normal file
1325
client/ui/i18n/locales/ja/common.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,9 @@ func main() {
|
||||
// daemon may keep the main window from showing, so the OS toast is the
|
||||
// only reliable signal the user gets.
|
||||
go notifyIfDaemonOutdated(compat, notifier, localizer)
|
||||
// One-time launch-on-login default for fresh installs; gated by the
|
||||
// NetBird footprint check, MDM policy, and the persisted marker.
|
||||
go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad())
|
||||
})
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
|
||||
@@ -54,6 +54,10 @@ type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
// AutostartInitialized records that the one-time autostart default
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
// and is never reset, so the default-on flow runs at most once, ever.
|
||||
AutostartInitialized bool `json:"autostartInitialized"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
@@ -72,8 +76,9 @@ type Emitter interface {
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
current UIPreferences
|
||||
mu sync.RWMutex
|
||||
current UIPreferences
|
||||
existedAtLoad bool
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs []chan UIPreferences
|
||||
@@ -157,6 +162,27 @@ func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAutostartInitialized persists the one-time autostart decision marker.
|
||||
// No-op if unchanged.
|
||||
func (s *Store) SetAutostartInitialized(done bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.AutostartInitialized == done {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.AutostartInitialized = done
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
@@ -206,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
// ExistedAtLoad reports whether the backing preferences file was present on
|
||||
// disk when the store loaded. It distinguishes a user who ran a prior GUI
|
||||
// version from a brand-new OS user with no preferences yet.
|
||||
func (s *Store) ExistedAtLoad() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.existedAtLoad
|
||||
}
|
||||
|
||||
// load reads the file into current. A missing file is not an error (the
|
||||
// in-memory default stands); malformed contents return an error.
|
||||
func (s *Store) load() error {
|
||||
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
if _, err := os.Stat(s.path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat preferences: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.existedAtLoad = true
|
||||
s.mu.Unlock()
|
||||
|
||||
var loaded UIPreferences
|
||||
if _, err := util.ReadJson(s.path, &loaded); err != nil {
|
||||
return err
|
||||
|
||||
@@ -215,6 +215,46 @@ func TestStore_FileShapeIsJSON(t *testing.T) {
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language)
|
||||
}
|
||||
|
||||
func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(nil, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk")
|
||||
|
||||
require.NoError(t, s.SetAutostartInitialized(true))
|
||||
assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker")
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast")
|
||||
|
||||
// Re-setting the same value must be a no-op: no disk write, no broadcast.
|
||||
require.NoError(t, s.SetAutostartInitialized(true))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again")
|
||||
|
||||
// A fresh Store (new GUI launch) must see the marker so the autostart
|
||||
// default decision never runs twice.
|
||||
reloaded, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
// Brand-new OS user: no preferences file on disk yet.
|
||||
fresh, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk")
|
||||
|
||||
// Persisting a value writes the file to disk.
|
||||
require.NoError(t, fresh.SetLanguage("en"))
|
||||
|
||||
// A subsequent GUI launch reopens the now-present file.
|
||||
reopened, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened")
|
||||
}
|
||||
|
||||
func TestStore_ErrUnsupportedSentinel(t *testing.T) {
|
||||
// Verifies callers can match on the sentinel error rather than parsing
|
||||
// strings — protects against accidental %v -> %w changes that would
|
||||
|
||||
@@ -35,7 +35,7 @@ type LoginResult struct {
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to WaitSSOLogin.
|
||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -125,23 +125,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Infof("waiting for SSO login to complete")
|
||||
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
||||
UserCode: p.UserCode,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -162,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the
|
||||
// connection up, both from the Go side. Keeping the post-login Up here rather
|
||||
// than as a frontend continuation is deliberate: during SSO the tray window is
|
||||
// hidden and the webview is suspended (macOS App Nap / hidden-window timer
|
||||
// throttling), so a frontend-driven Up would not run until the user woke the
|
||||
// window (e.g. by hovering the tray icon). Doing it in Go connects the moment
|
||||
// the daemon reports SSO success. Returns the authenticated user's email.
|
||||
func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) {
|
||||
email, err := s.waitSSOLogin(ctx, wait)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.Up(ctx, up); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -221,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitSSOLogin blocks until the daemon reports the SSO login result and returns
|
||||
// the authenticated user's email. It is unexported because the frontend drives
|
||||
// SSO through the exported WaitSSOLoginAndUp.
|
||||
func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Infof("waiting for SSO login to complete")
|
||||
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
||||
UserCode: p.UserCode,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
// classifyDaemonError maps a gRPC error to a localised ClientError.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
return s.classifier.classify(err)
|
||||
|
||||
@@ -20,11 +20,12 @@ type MDMFields struct {
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
DisableAutostart bool `json:"disableAutostart"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
||||
SplitTunnelMode bool `json:"splitTunnelMode"`
|
||||
SplitTunnelApps bool `json:"splitTunnelApps"`
|
||||
DisableAdvancedView bool `json:"disableAdvancedView"`
|
||||
DisableAdvancedView bool `json:"disableAdvancedView"`
|
||||
}
|
||||
|
||||
type Features struct {
|
||||
|
||||
@@ -145,6 +145,7 @@ 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
|
||||
@@ -604,6 +605,7 @@ 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 != "" {
|
||||
|
||||
@@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
|
||||
}
|
||||
|
||||
hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret))
|
||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
|
||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
|
||||
|
||||
relayCfg := relayServer.Config{
|
||||
Meter: s.metricsServer.Meter,
|
||||
|
||||
@@ -91,7 +91,7 @@ func availableProviders() []providerCase {
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireMessages})
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -224,9 +224,12 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
var c int
|
||||
var b string
|
||||
var cerr error
|
||||
if pc.kind == harness.WireVertex {
|
||||
switch pc.kind {
|
||||
case harness.WireVertex:
|
||||
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID)
|
||||
} else {
|
||||
case harness.WireBedrock:
|
||||
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
|
||||
default:
|
||||
c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
|
||||
}
|
||||
if cerr == nil {
|
||||
|
||||
168
e2e/agentnetwork/guardrail_test.go
Normal file
168
e2e/agentnetwork/guardrail_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// catalogModel returns the normalized catalog id the proxy stamps for a
|
||||
// path-routed provider's configured model — the form the guardrail allowlist is
|
||||
// compared against (region prefix / @version stripped).
|
||||
func catalogModel(pc providerCase) string {
|
||||
switch pc.kind {
|
||||
case harness.WireBedrock:
|
||||
return strings.TrimPrefix(pc.model, "us.")
|
||||
case harness.WireVertex:
|
||||
return strings.SplitN(pc.model, "@", 2)[0]
|
||||
default:
|
||||
return pc.model
|
||||
}
|
||||
}
|
||||
|
||||
// disallowedModel returns a valid-shaped model id for the provider that is NOT
|
||||
// the configured/allowed one, so the guardrail must reject it before the
|
||||
// request ever reaches the upstream.
|
||||
func disallowedModel(pc providerCase) string {
|
||||
switch pc.kind {
|
||||
case harness.WireBedrock:
|
||||
return "us.anthropic.claude-opus-4-8"
|
||||
case harness.WireVertex:
|
||||
return "claude-opus-4-8@20250101"
|
||||
default:
|
||||
return "unlisted-model"
|
||||
}
|
||||
}
|
||||
|
||||
// sendModel drives one request for the given model through the provider's native
|
||||
// wire shape and returns the HTTP status.
|
||||
func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, proxyIP string, pc providerCase, model string) int {
|
||||
t.Helper()
|
||||
var code int
|
||||
var err error
|
||||
switch pc.kind {
|
||||
case harness.WireBedrock:
|
||||
code, _, err = cl.Bedrock(ctx, endpoint, proxyIP, model, "Reply with exactly: pong", "")
|
||||
case harness.WireVertex:
|
||||
code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "")
|
||||
default:
|
||||
code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "")
|
||||
}
|
||||
require.NoError(t, err, "request must reach the proxy for %s", pc.name)
|
||||
return code
|
||||
}
|
||||
|
||||
// TestModelAllowlistEnforced provisions a Model Allowlist guardrail limiting each
|
||||
// path-routed provider (Bedrock, Vertex) to its configured model, then drives
|
||||
// requests over the tunnel: the allowed model returns 200 while a model outside
|
||||
// the allowlist is denied 403 by the guardrail before it reaches the upstream.
|
||||
// This is the coverage missing for #6751 — the model for these providers travels
|
||||
// in the URL path, and the allowlist must be enforced there.
|
||||
func TestModelAllowlistEnforced(t *testing.T) {
|
||||
var providers []providerCase
|
||||
for _, pc := range availableProviders() {
|
||||
if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex {
|
||||
providers = append(providers, pc)
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-allowlist"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-allowlist-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
|
||||
// Providers with their configured (allowed) models; the first bootstraps the cluster.
|
||||
ids := make([]string, 0, len(providers))
|
||||
allowed := make([]string, 0, len(providers))
|
||||
for i, pc := range providers {
|
||||
req := providerRequest(pc)
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
id := prov.Id
|
||||
ids = append(ids, id)
|
||||
allowed = append(allowed, catalogModel(pc))
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
|
||||
}
|
||||
|
||||
// Guardrail allowlisting exactly the configured models.
|
||||
var gr api.AgentNetworkGuardrailRequest
|
||||
gr.Name = "e2e-allowlist"
|
||||
gr.Checks.ModelAllowlist.Enabled = true
|
||||
gr.Checks.ModelAllowlist.Models = allowed
|
||||
guard, err := srv.CreateGuardrail(ctx, gr)
|
||||
require.NoError(t, err, "create guardrail")
|
||||
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
|
||||
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-allowlist",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: ids,
|
||||
GuardrailIds: &[]string{guard.Id},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
settings, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "read settings for endpoint")
|
||||
require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned")
|
||||
|
||||
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-allowlist")
|
||||
require.NoError(t, err, "mint proxy token via CLI")
|
||||
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
||||
require.NoError(t, err, "start proxy")
|
||||
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
||||
|
||||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
|
||||
for _, pc := range providers {
|
||||
pc := pc
|
||||
t.Run(pc.name, func(t *testing.T) {
|
||||
// The admin's allowlisted model is served end to end.
|
||||
assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model),
|
||||
"allowlisted model must be permitted for %s", pc.name)
|
||||
// A model outside the allowlist is rejected by the guardrail (before
|
||||
// the upstream), regardless of whether it is a real catalog model.
|
||||
assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)),
|
||||
"model outside the allowlist must be denied for %s", pc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,17 @@ func (c *Combined) DeletePolicy(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/policies/"+id)
|
||||
}
|
||||
|
||||
// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist)
|
||||
// that can then be attached to a policy via its GuardrailIds.
|
||||
func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) {
|
||||
return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req)
|
||||
}
|
||||
|
||||
// DeleteGuardrail removes a guardrail by id.
|
||||
func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. It exists only
|
||||
// after the first provider create bootstraps it.
|
||||
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
|
||||
|
||||
@@ -194,6 +194,11 @@ const (
|
||||
// WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts
|
||||
// the full Vertex model path and the proxy mints the SA OAuth token.
|
||||
WireVertex = "vertex"
|
||||
// WireBedrock is the native AWS Bedrock InvokeModel shape: the model id
|
||||
// travels in the URL path (/model/{id}/invoke), not the body, so the proxy
|
||||
// routes by path. This is what a Bedrock SDK client sends and the shape the
|
||||
// model-allowlist guardrail must enforce.
|
||||
WireBedrock = "bedrock"
|
||||
)
|
||||
|
||||
// Chat issues a chat-completion POST to the agent-network endpoint over the
|
||||
@@ -226,6 +231,17 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
|
||||
}
|
||||
|
||||
// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The
|
||||
// model id is carried in the request path (/model/{id}/invoke), so the proxy
|
||||
// routes by path; the body uses the bedrock anthropic_version rather than a
|
||||
// model field. A non-empty sessionID is sent as the universal x-session-id
|
||||
// header the proxy records.
|
||||
func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) {
|
||||
path := "/model/" + model + "/invoke"
|
||||
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
|
||||
}
|
||||
|
||||
// withSessionID appends the x-session-id header when sessionID is non-empty.
|
||||
func withSessionID(headers []string, sessionID string) []string {
|
||||
if sessionID == "" {
|
||||
|
||||
5
go.mod
5
go.mod
@@ -113,7 +113,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
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,7 +303,6 @@ 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
|
||||
@@ -335,7 +334,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.20260512110716-8d70ad8647c1
|
||||
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5
|
||||
|
||||
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||
|
||||
|
||||
10
go.sum
10
go.sum
@@ -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.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 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/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,10 +660,8 @@ 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.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/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
||||
@@ -613,6 +613,10 @@ 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 {
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"github.com/dexidp/dex/server/signer"
|
||||
"github.com/dexidp/dex/storage"
|
||||
"github.com/dexidp/dex/storage/sql"
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -595,3 +595,90 @@ 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)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@ 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.
|
||||
@@ -329,6 +335,18 @@ 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",
|
||||
|
||||
@@ -540,6 +540,7 @@ 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
|
||||
@@ -583,9 +584,11 @@ 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 — extras
|
||||
// can still apply, see below.
|
||||
if entry.IdentityInjection != nil {
|
||||
// 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 {
|
||||
switch {
|
||||
case entry.IdentityInjection.HeaderPair != nil:
|
||||
rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair)
|
||||
@@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject
|
||||
UserKey: userKey,
|
||||
GroupsKey: groupsKey,
|
||||
MaxValueLength: jm.MaxValueLength,
|
||||
Sanitize: jm.Sanitize,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -698,6 +698,94 @@ 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 /
|
||||
|
||||
@@ -51,6 +51,12 @@ 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
|
||||
@@ -137,6 +143,9 @@ 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
|
||||
@@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
||||
Models: models,
|
||||
Enabled: p.Enabled,
|
||||
SkipTlsVerification: p.SkipTLSVerification,
|
||||
MetadataDisabled: p.MetadataDisabled,
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
|
||||
@@ -42,3 +42,38 @@ 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")
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (e *EphemeralManager) cleanup(ctx context.Context) {
|
||||
}
|
||||
|
||||
for accountID, peerIDs := range peerIDsPerAccount {
|
||||
log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID)
|
||||
log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs)
|
||||
err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err)
|
||||
|
||||
@@ -184,6 +184,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID)
|
||||
|
||||
if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") {
|
||||
eventsToStore = append(eventsToStore, func() {
|
||||
m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain))
|
||||
@@ -224,30 +226,6 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dedupe stale embedded peer records for the same (account, cluster).
|
||||
// The proxy generates a fresh WireGuard keypair on every startup
|
||||
// (proxy/internal/roundtrip/netbird.go), so without this sweep the
|
||||
// prior embedded peer would linger forever — holding its CGNAT IP
|
||||
// allocation, polluting other peers' rosters, and (most visibly)
|
||||
// leaving the synth DNS pointing at the dead address. The
|
||||
// (account, cluster) tuple identifies "the embedded peer for this
|
||||
// proxy instance at this cluster"; any record matching that tuple
|
||||
// with a different pubkey is by definition stale and must go.
|
||||
staleIDs, err := m.findStaleEmbeddedProxyPeers(ctx, accountID, cluster, peerKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan for stale embedded proxy peers: %w", err)
|
||||
}
|
||||
if len(staleIDs) > 0 {
|
||||
// userID="" + checkConnected=false: the deletion is initiated
|
||||
// by management itself on behalf of the freshly-registering
|
||||
// proxy, not by an end user; the stale peer may still be
|
||||
// marked Connected from its prior session, but its session is
|
||||
// dead by definition (its key no longer exists).
|
||||
if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil {
|
||||
return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err)
|
||||
}
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("proxy-%s", xid.New().String())
|
||||
newPeer := &peer.Peer{
|
||||
Ephemeral: true,
|
||||
@@ -273,29 +251,3 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findStaleEmbeddedProxyPeers returns the peer IDs of embedded proxy peer
|
||||
// records in accountID that target the same cluster but carry a different
|
||||
// WireGuard pubkey than the freshly-registering one. Used by CreateProxyPeer
|
||||
// to garbage-collect stale records left behind when the proxy restarts with a
|
||||
// regenerated keypair.
|
||||
func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) {
|
||||
account, err := m.store.GetAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stale []string
|
||||
for _, p := range account.Peers {
|
||||
if p == nil || !p.ProxyMeta.Embedded {
|
||||
continue
|
||||
}
|
||||
if p.ProxyMeta.Cluster != cluster {
|
||||
continue
|
||||
}
|
||||
if p.Key == newKey {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, p.ID)
|
||||
}
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI
|
||||
m.turnCancelMap[peerID] = turnCancel
|
||||
go m.refreshTURNTokens(ctx, accountID, peerID, turnCancel)
|
||||
log.WithContext(ctx).Debugf("starting TURN refresh for %s", peerID)
|
||||
} else {
|
||||
log.WithContext(ctx).Debugf("no TURN configuration, skipping TURN refresh for %s", peerID)
|
||||
}
|
||||
|
||||
if m.relayCfg != nil {
|
||||
@@ -168,6 +170,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI
|
||||
m.relayCancelMap[peerID] = relayCancel
|
||||
go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel)
|
||||
log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID)
|
||||
} else {
|
||||
log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
// nolint:gosec
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe("localhost:6060", nil))
|
||||
}()
|
||||
if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" {
|
||||
log.Infof("pprof enabled, listening on: %s", pprofAddr)
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe(pprofAddr, nil))
|
||||
}()
|
||||
}
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
}
|
||||
|
||||
// Sanity: an actually different range still triggers reallocation.
|
||||
newRange := netip.MustParsePrefix("100.99.0.0/16")
|
||||
newRange := netip.MustParsePrefix("100.60.0.0/16")
|
||||
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
|
||||
PeerLoginExpirationEnabled: true,
|
||||
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale is the no-mock
|
||||
// regression guard for the bug the user reported: restarting the proxy creates
|
||||
// a fresh embedded peer with a NEW WireGuard public key (the proxy generates
|
||||
// the keypair on every startup at proxy/internal/roundtrip/netbird.go:312).
|
||||
// The PRIOR embedded peer record is never deleted on management, so the
|
||||
// account accumulates a stale peer holding a stale CGNAT IP. Other peers
|
||||
// in the account either keep routing to the dead IP, or — if synth DNS
|
||||
// picks the wrong record — never see the new IP at all.
|
||||
//
|
||||
// What this test exercises (no mocks):
|
||||
// - real SQLite test store
|
||||
// - real DefaultAccountManager, network-map controller, peer-update channels
|
||||
// - real peers.Manager.CreateProxyPeer path (the very method the proxy
|
||||
// invokes over gRPC on every startup)
|
||||
// - real agentnetwork.Manager + synth chain so the client receives a
|
||||
// concrete DNS record that must point at the LATEST proxy peer.
|
||||
//
|
||||
// Pre-fix expected behavior (red): two embedded peers exist after the
|
||||
// "restart"; the synth DNS record points at the stale one; the client
|
||||
// receives an update reflecting the new peer but the old one lingers.
|
||||
// Post-fix expected behavior (green): exactly one embedded peer exists
|
||||
// after restart (with the new key) AND the client's network map carries
|
||||
// the synth DNS pointing at that new peer's CGNAT IP.
|
||||
func TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale(t *testing.T) {
|
||||
am, updateManager, err := createManager(t)
|
||||
require.NoError(t, err, "createManager must succeed")
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "an-restart-acct"
|
||||
adminUserID = "an-restart-admin"
|
||||
groupAID = "an-restart-grp-A"
|
||||
clusterAddr = "eu.proxy.netbird.io"
|
||||
clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
|
||||
// Two different proxy pubkeys — the "before" and "after" of a
|
||||
// proxy-process restart with fresh-keypair generation.
|
||||
proxyKey1 = "Aaaaa1aaaaYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
|
||||
proxyKey2 = "Bbbbb2bbbbYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
|
||||
)
|
||||
|
||||
// --- Account scaffold ---
|
||||
account := newAccountWithId(ctx, accountID, adminUserID, "an-restart.test", "", "", false)
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account))
|
||||
|
||||
clientPeer := &nbpeer.Peer{
|
||||
Key: clientKey,
|
||||
Name: "an-restart-client",
|
||||
DNSLabel: "an-restart-client",
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "an-restart-client", GoOS: "linux", WtVersion: "development"},
|
||||
}
|
||||
addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false)
|
||||
require.NoError(t, err, "AddPeer for client must succeed")
|
||||
require.NoError(t, am.MarkPeerConnected(ctx, clientKey, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
|
||||
"MarkPeerConnected for the client peer must succeed (affected-peer fan-out skips disconnected peers)")
|
||||
|
||||
// Place the client in group A so the synth policy reaches it.
|
||||
account, err = am.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}}
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist group A")
|
||||
|
||||
// --- Real peers + agent-network managers ---
|
||||
permMgr := permissions.NewManager(am.Store)
|
||||
peersMgr := peers.NewManager(am.Store, permMgr)
|
||||
peersMgr.SetAccountManager(am)
|
||||
peersMgr.SetNetworkMapController(am.networkMapController)
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permMgr, am, nil)
|
||||
|
||||
// Subscribe BEFORE any state-mutating call so we don't lose the update
|
||||
// that contains the synth DNS record.
|
||||
clientCh := updateManager.CreateChannel(ctx, addedClient.ID)
|
||||
t.Cleanup(func() { updateManager.CloseChannel(ctx, addedClient.ID) })
|
||||
drain(clientCh)
|
||||
|
||||
// --- First proxy startup: register peer key K1, then mark it
|
||||
// connected. In production the proxy follows CreateProxyPeer with the
|
||||
// regular sync stream which lands on MarkPeerConnected; the synth DNS
|
||||
// path filters out peers that aren't Connected (types/account.go:323),
|
||||
// so without this step no DNS record would be emitted.
|
||||
require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey1, clusterAddr),
|
||||
"first CreateProxyPeer (proxy startup) must succeed")
|
||||
|
||||
peer1ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1)
|
||||
require.NoError(t, err, "proxy peer for K1 must be persisted after CreateProxyPeer")
|
||||
require.NotEmpty(t, peer1ID)
|
||||
|
||||
require.NoError(t, am.MarkPeerConnected(ctx, proxyKey1, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
|
||||
"MarkPeerConnected for K1 must succeed")
|
||||
|
||||
account, err = am.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
proxyIP1 := account.Peers[peer1ID].IP.String()
|
||||
require.NotEmpty(t, proxyIP1, "K1 must have an assigned overlay IP")
|
||||
|
||||
// --- Provider + policy. CreateProvider / CreatePolicy trigger the
|
||||
// agentnetwork reconcile which runs UpdateAccountPeers; the resulting
|
||||
// NetworkMap delivered to the client carries the synth DNS record
|
||||
// pointing at K1's IP. ---
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "openai-test",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test-key",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
_, err = agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{
|
||||
AccountID: accountID,
|
||||
Name: "p1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{groupAID},
|
||||
DestinationProviderIDs: []string{provider.ID},
|
||||
})
|
||||
require.NoError(t, err, "CreatePolicy must succeed")
|
||||
|
||||
settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
fqdn := settings.Endpoint()
|
||||
|
||||
rdata1 := awaitZoneRData(clientCh, clusterAddr, fqdn, true)
|
||||
require.Equal(t, proxyIP1, rdata1,
|
||||
"client must receive a synth DNS record pointing at K1's overlay IP after the synth path runs")
|
||||
drain(clientCh)
|
||||
|
||||
// --- Proxy restart: NEW keypair K2, same account, same cluster ---
|
||||
require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey2, clusterAddr),
|
||||
"second CreateProxyPeer (proxy restart with fresh keypair) must succeed")
|
||||
|
||||
peer2ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey2)
|
||||
require.NoError(t, err, "proxy peer for K2 must be persisted after restart")
|
||||
require.NotEmpty(t, peer2ID)
|
||||
|
||||
require.NoError(t, am.MarkPeerConnected(ctx, proxyKey2, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
|
||||
"MarkPeerConnected for K2 must succeed")
|
||||
|
||||
// In production the agent's sync stream pulls a fresh NetworkMap as
|
||||
// part of its normal reconcile cadence; in this isolated test
|
||||
// MarkPeerConnected's affected-peer fan-out can race the channel-side
|
||||
// buffer in a way that swallows the synth-DNS-bearing update before
|
||||
// our await reads it. Trigger an explicit account-wide fan-out so the
|
||||
// assertion below tests what production actually delivers, not the
|
||||
// in-test buffer race.
|
||||
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
|
||||
|
||||
account, err = am.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
proxyIP2 := account.Peers[peer2ID].IP.String()
|
||||
require.NotEmpty(t, proxyIP2, "K2 must have an assigned overlay IP")
|
||||
require.NotEqual(t, proxyIP1, proxyIP2, "K2 must get a different overlay IP than K1 (sanity)")
|
||||
|
||||
// CRITICAL ASSERTION 1: K1 must no longer be in the store. The SqlStore
|
||||
// returns ("", nil) for a missing key rather than NotFound, so assert
|
||||
// on the returned ID being empty.
|
||||
staleID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1)
|
||||
require.NoError(t, err, "GetPeerIDByKey for a missing peer must not error")
|
||||
assert.Empty(t, staleID,
|
||||
"stale embedded proxy peer K1 must be removed when a new embedded peer registers for the same (account, cluster); pre-fix this assertion fails because management never cleans up the prior peer record")
|
||||
|
||||
// CRITICAL ASSERTION 2: exactly one embedded proxy peer remains, and it
|
||||
// is K2.
|
||||
account, err = am.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
embeddedKeys := []string{}
|
||||
for _, p := range account.Peers {
|
||||
if p.ProxyMeta.Embedded {
|
||||
embeddedKeys = append(embeddedKeys, p.Key)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{proxyKey2}, embeddedKeys,
|
||||
"after a proxy restart exactly one embedded proxy peer should remain — the one with the new key K2")
|
||||
|
||||
// CRITICAL ASSERTION 3: the synth DNS record the client receives now
|
||||
// points at K2's IP, not K1's.
|
||||
rdata2 := awaitZoneRData(clientCh, clusterAddr, fqdn, true)
|
||||
assert.Equal(t, proxyIP2, rdata2,
|
||||
"after proxy restart, the client's synth DNS record must point at the NEW embedded peer's IP, not the stale K1 IP")
|
||||
}
|
||||
@@ -289,6 +289,18 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
|
||||
if req.Settings.AgentNetworkOnly != nil {
|
||||
returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly
|
||||
}
|
||||
if req.Settings.DashboardFeatures != nil {
|
||||
returnSettings.DashboardFeatures = &types.DashboardFeatures{
|
||||
AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork,
|
||||
}
|
||||
}
|
||||
|
||||
if returnSettings.AgentNetworkOnly &&
|
||||
(returnSettings.DashboardFeatures == nil ||
|
||||
returnSettings.DashboardFeatures.AgentNetwork == nil ||
|
||||
!*returnSettings.DashboardFeatures.AgentNetwork) {
|
||||
return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled")
|
||||
}
|
||||
|
||||
return returnSettings, nil
|
||||
}
|
||||
@@ -434,6 +446,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
networkRangeV6Str := settings.NetworkRangeV6.String()
|
||||
apiSettings.NetworkRangeV6 = &networkRangeV6Str
|
||||
}
|
||||
if settings.DashboardFeatures != nil {
|
||||
apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{
|
||||
AgentNetwork: settings.DashboardFeatures.AgentNetwork,
|
||||
}
|
||||
}
|
||||
|
||||
apiOnboarding := api.AccountOnboarding{
|
||||
OnboardingFlowPending: onboarding.OnboardingFlowPending,
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
expectedBody: true,
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/api/accounts/" + accountID,
|
||||
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
|
||||
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedSettings: api.AccountSettings{
|
||||
PeerLoginExpiration: 15552000,
|
||||
@@ -305,9 +305,53 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
AgentNetworkOnly: br(true),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
LocalMfaEnabled: br(false),
|
||||
DashboardFeatures: &api.AccountDashboardFeatures{
|
||||
AgentNetwork: br(true),
|
||||
},
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
LocalMfaEnabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
},
|
||||
{
|
||||
name: "PutAccount fails enabling agent_network_only without dashboard_features",
|
||||
expectedBody: true,
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/api/accounts/" + accountID,
|
||||
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedArray: false,
|
||||
},
|
||||
{
|
||||
name: "PutAccount OK setting dashboard_features agent_network",
|
||||
expectedBody: true,
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/api/accounts/" + accountID,
|
||||
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedSettings: api.AccountSettings{
|
||||
PeerLoginExpiration: 15552000,
|
||||
PeerLoginExpirationEnabled: true,
|
||||
GroupsPropagationEnabled: br(false),
|
||||
JwtGroupsClaimName: sr(""),
|
||||
JwtGroupsEnabled: br(false),
|
||||
JwtAllowGroups: &[]string{},
|
||||
RegularUsersViewBlocked: false,
|
||||
RoutingPeerDnsResolutionEnabled: br(false),
|
||||
LazyConnectionEnabled: br(false),
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
DashboardFeatures: &api.AccountDashboardFeatures{
|
||||
AgentNetwork: br(true),
|
||||
},
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
LocalMfaEnabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
|
||||
@@ -76,6 +76,9 @@ 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.
|
||||
@@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
},
|
||||
OAuth2: dex.OAuth2{
|
||||
SkipApprovalScreen: true,
|
||||
GrantTypes: c.GrantTypes,
|
||||
},
|
||||
Frontend: dex.Frontend{
|
||||
Issuer: "NetBird",
|
||||
|
||||
@@ -106,11 +106,13 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
|
||||
}
|
||||
if !updated {
|
||||
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale)
|
||||
log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID)
|
||||
log.WithContext(ctx).Debugf("peer %s already has a newer session in store, skipping connect", peer.ID)
|
||||
return nil
|
||||
}
|
||||
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied)
|
||||
|
||||
log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID)
|
||||
|
||||
if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,12 +182,14 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
|
||||
}
|
||||
if !updated {
|
||||
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale)
|
||||
log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping",
|
||||
log.WithContext(ctx).Debugf("peer %s session token mismatch on disconnect (token=%d), skipping",
|
||||
peer.ID, sessionStartedAt)
|
||||
return nil
|
||||
}
|
||||
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
|
||||
|
||||
log.WithContext(ctx).Debugf("mark peer %s disconnected", peer.ID)
|
||||
|
||||
// Symmetric with MarkPeerConnected: when an embedded proxy peer goes
|
||||
// offline, refresh the peers that had synthesized records pointing at
|
||||
// it so they pull the stale entries instead of waiting out TTL.
|
||||
|
||||
@@ -1606,6 +1606,8 @@ 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,
|
||||
-- Embedded ExtraSettings
|
||||
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
|
||||
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
|
||||
@@ -1630,6 +1632,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
|
||||
sLocalMFAEnabled sql.NullBool
|
||||
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
|
||||
@@ -1653,6 +1660,8 @@ 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,
|
||||
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
|
||||
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
|
||||
)
|
||||
@@ -1724,6 +1733,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
|
||||
if sAgentNetworkOnly.Valid {
|
||||
account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool
|
||||
}
|
||||
if sDashboardFeatures.Valid && sDashboardFeatures.String != "" {
|
||||
if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
if sJWTAllowGroups.Valid {
|
||||
_ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups)
|
||||
}
|
||||
@@ -1739,6 +1753,18 @@ 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
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
@@ -34,6 +35,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -296,6 +298,53 @@ 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")
|
||||
@@ -1270,6 +1319,36 @@ func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) {
|
||||
require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist")
|
||||
}
|
||||
|
||||
func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
account, err := store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset")
|
||||
|
||||
agentNetwork := true
|
||||
account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account))
|
||||
|
||||
reloaded, err := store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip")
|
||||
require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set")
|
||||
require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true")
|
||||
|
||||
disabled := false
|
||||
reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), reloaded))
|
||||
|
||||
reloadedDisabled, err := store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set")
|
||||
require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist")
|
||||
}
|
||||
|
||||
func TestSqlStore_GetAccountUsers(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ssh/auth"
|
||||
@@ -42,6 +43,14 @@ type NetworkMapComponents struct {
|
||||
PostureFailedPeers map[string]map[string]struct{}
|
||||
|
||||
RouterPeers map[string]*nbpeer.Peer
|
||||
|
||||
routesByPeerOnce sync.Once
|
||||
routesByPeerIdx map[string][]routeIndexEntry
|
||||
}
|
||||
|
||||
type routeIndexEntry struct {
|
||||
route *route.Route
|
||||
viaGroup bool
|
||||
}
|
||||
|
||||
type AccountSettingsInfo struct {
|
||||
@@ -530,33 +539,43 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
|
||||
disabledRoutes = append(disabledRoutes, r)
|
||||
}
|
||||
|
||||
for _, r := range c.Routes {
|
||||
for _, groupID := range r.PeerGroups {
|
||||
group := c.GetGroupInfo(groupID)
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range group.Peers {
|
||||
if id != peerID {
|
||||
continue
|
||||
}
|
||||
|
||||
newPeerRoute := r.Copy()
|
||||
newPeerRoute.Peer = id
|
||||
newPeerRoute.PeerGroups = nil
|
||||
newPeerRoute.ID = route.ID(string(r.ID) + ":" + id)
|
||||
takeRoute(newPeerRoute)
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.Peer == peerID {
|
||||
takeRoute(r.Copy())
|
||||
for _, entry := range c.routesByPeer()[peerID] {
|
||||
if entry.viaGroup {
|
||||
newPeerRoute := entry.route.Copy()
|
||||
newPeerRoute.PeerGroups = nil
|
||||
newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
|
||||
takeRoute(newPeerRoute)
|
||||
continue
|
||||
}
|
||||
takeRoute(entry.route.Copy())
|
||||
}
|
||||
|
||||
return enabledRoutes, disabledRoutes
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
|
||||
c.routesByPeerOnce.Do(func() {
|
||||
idx := make(map[string][]routeIndexEntry)
|
||||
for _, r := range c.Routes {
|
||||
for _, groupID := range r.PeerGroups {
|
||||
group := c.GetGroupInfo(groupID)
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range group.Peers {
|
||||
idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true})
|
||||
}
|
||||
}
|
||||
if r.Peer != "" {
|
||||
idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r})
|
||||
}
|
||||
}
|
||||
c.routesByPeerIdx = idx
|
||||
})
|
||||
|
||||
return c.routesByPeerIdx
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
|
||||
var filteredRoutes []*route.Route
|
||||
for _, r := range routes {
|
||||
|
||||
@@ -80,6 +80,11 @@ type Settings struct {
|
||||
// Set for accounts created via netbird.ai signups; users can disable it later.
|
||||
AgentNetworkOnly bool `gorm:"default:false"`
|
||||
|
||||
// DashboardFeatures holds per-account dashboard section visibility overrides.
|
||||
// It serializes to a single JSON column so new sections can be added without
|
||||
// a schema change.
|
||||
DashboardFeatures *DashboardFeatures `gorm:"serializer:json"`
|
||||
|
||||
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
|
||||
// This is a runtime-only field, not stored in the database.
|
||||
EmbeddedIdpEnabled bool `gorm:"-"`
|
||||
@@ -126,9 +131,31 @@ func (s *Settings) Copy() *Settings {
|
||||
if s.Extra != nil {
|
||||
settings.Extra = s.Extra.Copy()
|
||||
}
|
||||
if s.DashboardFeatures != nil {
|
||||
settings.DashboardFeatures = s.DashboardFeatures.Copy()
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// DashboardFeatures holds per-account dashboard section visibility overrides.
|
||||
// Nil fields are unset and follow the default dashboard behavior; an explicit
|
||||
// value forces that section shown or hidden for the account.
|
||||
type DashboardFeatures struct {
|
||||
// AgentNetwork, when set, forces the Agent Network menu shown (true) or
|
||||
// hidden (false) regardless of the deployment feature flag.
|
||||
AgentNetwork *bool `json:"agent_network,omitempty"`
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the DashboardFeatures struct.
|
||||
func (d *DashboardFeatures) Copy() *DashboardFeatures {
|
||||
c := &DashboardFeatures{}
|
||||
if d.AgentNetwork != nil {
|
||||
v := *d.AgentNetwork
|
||||
c.AgentNetwork = &v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type ExtraSettings struct {
|
||||
// PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator
|
||||
PeerApprovalEnabled bool
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
|
||||
}
|
||||
|
||||
parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies)
|
||||
parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 (
|
||||
@@ -66,7 +67,7 @@ type denyBucket struct {
|
||||
type Logger struct {
|
||||
client gRPCClient
|
||||
logger *log.Logger
|
||||
trustedProxies []netip.Prefix
|
||||
trustedProxies *trustedproxy.List
|
||||
|
||||
usageMux sync.Mutex
|
||||
domainUsage map[string]*domainUsage
|
||||
@@ -82,7 +83,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 []netip.Prefix) *Logger {
|
||||
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
// 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 []netip.Prefix) netip.Addr {
|
||||
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
||||
func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr {
|
||||
return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
|
||||
38
proxy/internal/llm/bedrock_model.go
Normal file
38
proxy/internal/llm/bedrock_model.go
Normal file
@@ -0,0 +1,38 @@
|
||||
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, "")
|
||||
}
|
||||
23
proxy/internal/llm/bedrock_model_test.go
Normal file
23
proxy/internal/llm/bedrock_model_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ const (
|
||||
denyCodeModel = "llm_policy.model_blocked"
|
||||
denyReasonModel = "model_blocked"
|
||||
denyMessageModel = "model is not in the policy allowlist"
|
||||
// Deny reason used when an allowlist is configured but the request model
|
||||
// could not be determined. URL/path-routed providers (AWS Bedrock, Google
|
||||
// Vertex, ...) carry the model outside the JSON body, so a request shape the
|
||||
// parser does not recognise reaches the guardrail with no model. Such a
|
||||
// request must be denied (fail closed), never waved through.
|
||||
denyCodeModelUnknown = "llm_policy.model_unknown"
|
||||
denyReasonModelUnknown = "model_unknown"
|
||||
denyMessageModelUnknown = "request model could not be determined for the policy allowlist"
|
||||
)
|
||||
|
||||
// Middleware enforces the model allowlist and optionally captures the
|
||||
@@ -108,23 +116,37 @@ func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middlew
|
||||
if len(m.cfg.ModelAllowlist) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !modelPresent {
|
||||
return nil
|
||||
// Fail closed: with an allowlist configured, a request whose model the
|
||||
// upstream parser could not extract (absent or empty) must be denied rather
|
||||
// than allowed. This is what enforces the allowlist for URL/path-routed
|
||||
// providers (Bedrock, Vertex, ...) whose model lives outside the JSON body.
|
||||
if !modelPresent || normaliseModel(model) == "" {
|
||||
return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
||||
}
|
||||
if m.modelInAllowlist(model) {
|
||||
return nil
|
||||
}
|
||||
return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel)
|
||||
}
|
||||
|
||||
// denyModel builds a 403 deny Output for a model-allowlist rejection. model is
|
||||
// included in the details only when non-empty.
|
||||
func denyModel(model, code, message, reason string) *middleware.Output {
|
||||
details := map[string]string{}
|
||||
if model != "" {
|
||||
details["model"] = model
|
||||
}
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Code: denyCodeModel,
|
||||
Message: denyMessageModel,
|
||||
Details: map[string]string{"model": model},
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
||||
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel},
|
||||
{Key: middleware.KeyLLMPolicyReason, Value: reason},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +102,44 @@ func TestAllowlistCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistMissingModelKeyAllows(t *testing.T) {
|
||||
func TestAllowlistMissingModelKeyDenies(t *testing.T) {
|
||||
// Fail closed: with an allowlist configured, a request whose model the
|
||||
// parser could not extract (URL/path-routed providers such as Bedrock or
|
||||
// Vertex whose shape wasn't recognised) must be denied, not allowed.
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist")
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set")
|
||||
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403")
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be populated")
|
||||
assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown")
|
||||
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
||||
assert.Equal(t, "allow", dec, "decision must be allow when model key is absent")
|
||||
assert.Equal(t, "deny", dec, "decision must be deny when model key is absent")
|
||||
reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason)
|
||||
assert.Equal(t, "model_unknown", reason, "reason metadata must be model_unknown")
|
||||
}
|
||||
|
||||
func TestAllowlistEmptyModelValueDenies(t *testing.T) {
|
||||
// A present-but-empty model is as undeterminable as an absent one.
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: " "},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set")
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be populated")
|
||||
assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown")
|
||||
}
|
||||
|
||||
func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) {
|
||||
// Without an allowlist there is nothing to enforce, so a missing model is
|
||||
// still allowed — the fail-closed rule only applies when a list is set.
|
||||
mw := New(Config{})
|
||||
out, err := mw.Invoke(context.Background(), newInput())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model")
|
||||
}
|
||||
|
||||
func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) {
|
||||
|
||||
@@ -64,6 +64,11 @@ 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
|
||||
|
||||
@@ -292,15 +292,21 @@ 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] = truncate(identity, rule.MaxValueLength)
|
||||
payload[rule.UserKey] = emit(identity)
|
||||
}
|
||||
}
|
||||
if rule.GroupsKey != "" {
|
||||
if csv := authorisingTagsCSV(in); csv != "" {
|
||||
payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength)
|
||||
payload[rule.GroupsKey] = emit(csv)
|
||||
}
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
@@ -359,6 +365,36 @@ 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
|
||||
|
||||
@@ -304,6 +304,46 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package llm_request_parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
|
||||
)
|
||||
|
||||
// runParserGuardrail runs the request parser then the model-allowlist guardrail
|
||||
// in SlotOnRequest order, threading the parser's metadata into the guardrail the
|
||||
// same way the real chain does. It returns the guardrail decision so tests can
|
||||
// assert allowlist enforcement for URL/path-routed providers end to end.
|
||||
func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []string) *middleware.Output {
|
||||
t.Helper()
|
||||
parser := newMiddleware(t)
|
||||
parsed, err := parser.Invoke(context.Background(), &middleware.Input{
|
||||
Slot: middleware.SlotOnRequest,
|
||||
URL: url,
|
||||
Body: body,
|
||||
})
|
||||
require.NoError(t, err, "parser must not error")
|
||||
|
||||
guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist})
|
||||
out, err := guard.Invoke(context.Background(), &middleware.Input{
|
||||
Slot: middleware.SlotOnRequest,
|
||||
Metadata: parsed.Metadata,
|
||||
})
|
||||
require.NoError(t, err, "guardrail must not error")
|
||||
require.NotNil(t, out, "guardrail must return an output")
|
||||
return out
|
||||
}
|
||||
|
||||
// TestModelAllowlist_URLRoutedProviders validates that the model allowlist is
|
||||
// enforced for providers whose model travels in the URL path (AWS Bedrock,
|
||||
// Google Vertex) rather than the JSON body. The "unknown action" case is the
|
||||
// regression guard for #6751: a Bedrock request shape the parser cannot map to a
|
||||
// model must fail closed under an allowlist instead of bypassing it.
|
||||
func TestModelAllowlist_URLRoutedProviders(t *testing.T) {
|
||||
const bedrockBody = `{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":"hi"}]}`
|
||||
const vertexBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
allowlist []string
|
||||
decision middleware.Decision
|
||||
denyCode string
|
||||
}{
|
||||
{
|
||||
name: "bedrock allowed model passes",
|
||||
url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-v1:0/invoke",
|
||||
body: bedrockBody,
|
||||
allowlist: []string{"anthropic.claude-haiku-4-5"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "bedrock disallowed model denied",
|
||||
url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/invoke",
|
||||
body: bedrockBody,
|
||||
allowlist: []string{"anthropic.claude-haiku-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
name: "bedrock unknown action fails closed",
|
||||
url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/some-future-action",
|
||||
body: bedrockBody,
|
||||
allowlist: []string{"anthropic.claude-haiku-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_unknown",
|
||||
},
|
||||
{
|
||||
name: "vertex disallowed model denied",
|
||||
url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-opus-4-8@20250101:rawPredict",
|
||||
body: vertexBody,
|
||||
allowlist: []string{"claude-haiku-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
name: "vertex allowed model passes",
|
||||
url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-haiku-4-5@20250101:rawPredict",
|
||||
body: vertexBody,
|
||||
allowlist: []string{"claude-haiku-4-5"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist)
|
||||
assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name)
|
||||
if tt.decision == middleware.DecisionDeny {
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name)
|
||||
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name)
|
||||
assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package llm_router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
|
||||
// Bedrock routing gap: the request model reaches the router already normalized
|
||||
// (the parser strips the region/inference-profile prefix and version suffix),
|
||||
// so a provider registered with the raw inference-profile id must still match.
|
||||
func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
||||
route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"),
|
||||
"raw region-prefixed Bedrock model must match the normalized request model")
|
||||
assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"),
|
||||
"a model outside the provider's list must not match")
|
||||
|
||||
// A provider registered with the already-normalized id also matches.
|
||||
normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"),
|
||||
"normalized Bedrock model must match")
|
||||
|
||||
// Non-Bedrock routes keep exact matching (no prefix stripping).
|
||||
openai := ProviderRoute{Models: []string{"gpt-4o"}}
|
||||
assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match")
|
||||
assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
|
||||
"non-Bedrock routes must not strip a us. prefix")
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
||||
if candidate == model {
|
||||
return true
|
||||
}
|
||||
// Bedrock request models reach the router already normalized (the parser
|
||||
// strips the region / inference-profile prefix and version suffix), but
|
||||
// the operator may register the raw inference-profile id (e.g.
|
||||
// "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides
|
||||
// compare equal; otherwise a native Bedrock request denies as not-routable.
|
||||
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 {
|
||||
@@ -29,10 +30,10 @@ type ReverseProxy struct {
|
||||
// forwardedProto overrides the X-Forwarded-Proto header value.
|
||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||
forwardedProto string
|
||||
// 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
|
||||
// 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
|
||||
mappingsMux sync.RWMutex
|
||||
mappings map[string]Mapping
|
||||
logger *log.Logger
|
||||
@@ -63,7 +64,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 []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy {
|
||||
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
@@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
|
||||
if !types.IsOverlayOrigin(r.Context()) {
|
||||
return false
|
||||
}
|
||||
srcIP := extractHostIP(r.RemoteAddr)
|
||||
srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr)
|
||||
if !srcIP.IsValid() {
|
||||
return false
|
||||
}
|
||||
@@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
|
||||
|
||||
stampNetBirdIdentity(r)
|
||||
|
||||
clientIP := extractHostIP(r.In.RemoteAddr)
|
||||
clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr)
|
||||
|
||||
if isTrustedAddr(clientIP, p.trustedProxies) {
|
||||
if p.trustedProxies.Contains(clientIP) {
|
||||
p.setTrustedForwardingHeaders(r, clientIP)
|
||||
} else {
|
||||
p.setUntrustedForwardingHeaders(r, clientIP)
|
||||
@@ -664,7 +665,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 := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
||||
resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"))
|
||||
r.Out.Header.Set("X-Real-IP", resolved.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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) {
|
||||
@@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
|
||||
assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) {
|
||||
|
||||
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
||||
target, _ := url.Parse("http://backend.internal:8080")
|
||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
trusted := trustedproxy.FromPrefixes([]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}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsTrustedProxy(t *testing.T) {
|
||||
trusted := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
trusted []netip.Prefix
|
||||
want bool
|
||||
}{
|
||||
{"empty trusted list", "10.0.0.1", nil, false},
|
||||
{"IP within /8 prefix", "10.1.2.3", trusted, true},
|
||||
{"IP within /24 prefix", "192.168.1.100", trusted, true},
|
||||
{"IP outside all prefixes", "203.0.113.50", trusted, false},
|
||||
{"boundary IP just outside prefix", "192.168.2.1", trusted, false},
|
||||
{"unparsable IP", "not-an-ip", trusted, false},
|
||||
{"IPv6 in trusted range", "fd00::1", trusted, true},
|
||||
{"IPv6 outside range", "2001:db8::1", trusted, false},
|
||||
{"empty string", "", trusted, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP(t *testing.T) {
|
||||
trusted := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
trusted []netip.Prefix
|
||||
want netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "empty trusted list returns RemoteAddr",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4",
|
||||
trusted: nil,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "untrusted RemoteAddr ignores XFF",
|
||||
remoteAddr: "203.0.113.50:9999",
|
||||
xff: "1.2.3.4, 10.0.0.1",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with single client in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.1"),
|
||||
},
|
||||
{
|
||||
name: "all XFF IPs trusted returns leftmost",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("10.0.0.2"),
|
||||
},
|
||||
{
|
||||
name: "XFF with whitespace",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "XFF with empty segments",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "203.0.113.50,,10.0.0.2",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "multi-hop with mixed trust",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr without port",
|
||||
remoteAddr: "10.0.0.1",
|
||||
xff: "203.0.113.50",
|
||||
trusted: trusted,
|
||||
want: netip.MustParseAddr("203.0.113.50"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/proxy/internal/acme"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
// Config bundles every knob the proxy reads at construction time. It mirrors
|
||||
@@ -83,9 +83,9 @@ type Config struct {
|
||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to
|
||||
// backends. Valid values: "auto", "http", "https".
|
||||
ForwardedProto string
|
||||
// TrustedProxies is a list of IP prefixes for trusted upstream
|
||||
// proxies that may set forwarding headers.
|
||||
TrustedProxies []netip.Prefix
|
||||
// TrustedProxies is the set of trusted upstream proxies that may set
|
||||
// forwarding headers.
|
||||
TrustedProxies *trustedproxy.List
|
||||
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
||||
// Zero asks the OS for a random port.
|
||||
WireguardPort uint16
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
)
|
||||
|
||||
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}),
|
||||
ProxyProtocol: true,
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
@@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
@@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
||||
srv := &Server{
|
||||
Logger: log.StandardLogger(),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||
}
|
||||
|
||||
opts := proxyproto.ConnPolicyOptions{
|
||||
|
||||
@@ -67,6 +67,7 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/web"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/util/embeddedroots"
|
||||
)
|
||||
|
||||
@@ -79,19 +80,19 @@ type portRouter struct {
|
||||
|
||||
type Server struct {
|
||||
ctx context.Context
|
||||
mgmtClient proto.ProxyServiceClient
|
||||
proxy *proxy.ReverseProxy
|
||||
netbird *roundtrip.NetBird
|
||||
acme *acme.Manager
|
||||
mgmtClient proto.ProxyServiceClient
|
||||
proxy *proxy.ReverseProxy
|
||||
netbird *roundtrip.NetBird
|
||||
acme *acme.Manager
|
||||
staticCertWatcher *certwatch.Watcher
|
||||
auth *auth.Middleware
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
debug *http.Server
|
||||
healthServer *health.Server
|
||||
healthChecker *health.Checker
|
||||
meter *proxymetrics.Metrics
|
||||
accessLog *accesslog.Logger
|
||||
auth *auth.Middleware
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
debug *http.Server
|
||||
healthServer *health.Server
|
||||
healthChecker *health.Checker
|
||||
meter *proxymetrics.Metrics
|
||||
accessLog *accesslog.Logger
|
||||
// middlewareManager drives per-target middleware dispatch. Always
|
||||
// constructed during boot; an empty registry produces empty chains and
|
||||
// the reverse-proxy stays on the no-capture fast path.
|
||||
@@ -99,16 +100,16 @@ type Server struct {
|
||||
// middlewareRegistry is the source of registered middleware factories.
|
||||
// Concrete middlewares register themselves through init().
|
||||
middlewareRegistry *middleware.Registry
|
||||
mainRouter *nbtcp.Router
|
||||
mainPort uint16
|
||||
udpMu sync.Mutex
|
||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||
udpRelayWg sync.WaitGroup
|
||||
portMu sync.RWMutex
|
||||
portRouters map[uint16]*portRouter
|
||||
svcPorts map[types.ServiceID][]uint16
|
||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||
portRouterWg sync.WaitGroup
|
||||
mainRouter *nbtcp.Router
|
||||
mainPort uint16
|
||||
udpMu sync.Mutex
|
||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||
udpRelayWg sync.WaitGroup
|
||||
portMu sync.RWMutex
|
||||
portRouters map[uint16]*portRouter
|
||||
svcPorts map[types.ServiceID][]uint16
|
||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||
portRouterWg sync.WaitGroup
|
||||
|
||||
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
||||
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
||||
@@ -192,10 +193,10 @@ type Server struct {
|
||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
|
||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||
ForwardedProto string
|
||||
// TrustedProxies is a list of IP prefixes for trusted upstream proxies.
|
||||
// When set, forwarding headers from these sources are preserved and
|
||||
// appended to instead of being stripped.
|
||||
TrustedProxies []netip.Prefix
|
||||
// TrustedProxies is the set of trusted upstream proxies. When set,
|
||||
// forwarding headers from these sources are preserved and appended to
|
||||
// instead of being stripped.
|
||||
TrustedProxies *trustedproxy.List
|
||||
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
||||
// for a random OS-assigned port. A fixed port only works with
|
||||
// single-account deployments; multiple accounts will fail to bind
|
||||
@@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener {
|
||||
Listener: ln,
|
||||
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
||||
}
|
||||
if len(s.TrustedProxies) > 0 {
|
||||
if !s.TrustedProxies.Empty() {
|
||||
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
||||
} else {
|
||||
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
|
||||
@@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
|
||||
addr = addr.Unmap()
|
||||
|
||||
// called per accept
|
||||
for _, prefix := range s.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
return proxyproto.REQUIRE, nil
|
||||
}
|
||||
if s.TrustedProxies.Contains(addr) {
|
||||
return proxyproto.REQUIRE, nil
|
||||
}
|
||||
return proxyproto.IGNORE, nil
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs
|
||||
// into a slice of netip.Prefix values suitable for trusted proxy configuration.
|
||||
// Bare IPs are converted to single-host prefixes (/32 or /128).
|
||||
func ParseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := netip.ParsePrefix(part)
|
||||
if err == nil {
|
||||
prefixes = append(prefixes, prefix)
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := netip.ParseAddr(part)
|
||||
if addrErr != nil {
|
||||
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
||||
}
|
||||
|
||||
bits := 32
|
||||
if addr.Is6() {
|
||||
bits = 128
|
||||
}
|
||||
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
||||
}
|
||||
return prefixes, nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []netip.Prefix
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string returns nil",
|
||||
raw: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single CIDR",
|
||||
raw: "10.0.0.0/8",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv4",
|
||||
raw: "1.2.3.4",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
||||
},
|
||||
{
|
||||
name: "single bare IPv6",
|
||||
raw: "::1",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
},
|
||||
{
|
||||
name: "comma-separated CIDRs",
|
||||
raw: "10.0.0.0/8, 192.168.1.0/24",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed CIDRs and bare IPs",
|
||||
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("1.2.3.4/32"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace around entries",
|
||||
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailing comma produces no extra entry",
|
||||
raw: "10.0.0.0/8,",
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "invalid entry",
|
||||
raw: "not-an-ip",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "partially invalid",
|
||||
raw: "10.0.0.0/8, garbage",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseTrustedProxies(tt.raw)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/metrics"
|
||||
"github.com/netbirdio/netbird/shared/relay/auth"
|
||||
"github.com/netbirdio/netbird/stun"
|
||||
"github.com/netbirdio/netbird/trustedproxy"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,9 @@ type Config struct {
|
||||
LogLevel string
|
||||
LogFile string
|
||||
HealthcheckListenAddress string
|
||||
// TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose
|
||||
// X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers.
|
||||
TrustedProxies string
|
||||
// STUN server configuration
|
||||
EnableSTUN bool
|
||||
STUNPorts []int
|
||||
@@ -116,6 +120,7 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
||||
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address")
|
||||
rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
|
||||
rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
|
||||
@@ -155,8 +160,15 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("setup metrics: %v", err)
|
||||
}
|
||||
|
||||
trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies)
|
||||
if err != nil {
|
||||
log.Debugf("failed to parse trusted proxies: %s", err)
|
||||
return fmt.Errorf("failed to parse trusted proxies: %s", err)
|
||||
}
|
||||
|
||||
srvListenerCfg := server.ListenerConfig{
|
||||
Address: cobraConfig.ListenAddress,
|
||||
Address: cobraConfig.ListenAddress,
|
||||
TrustedProxies: trustedProxies,
|
||||
}
|
||||
|
||||
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
||||
@@ -173,7 +185,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret))
|
||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
|
||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
|
||||
|
||||
cfg := server.Config{
|
||||
Meter: metricsServer.Meter,
|
||||
|
||||
@@ -5,14 +5,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/relay/server/listener"
|
||||
"github.com/netbirdio/netbird/shared/relay/messages"
|
||||
//nolint:staticcheck
|
||||
"github.com/netbirdio/netbird/shared/relay/messages/address"
|
||||
//nolint:staticcheck
|
||||
authmsg "github.com/netbirdio/netbird/shared/relay/messages/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,55 +17,30 @@ const (
|
||||
|
||||
type Validator interface {
|
||||
Validate(any) error
|
||||
// Deprecated: Use Validate instead.
|
||||
ValidateHelloMsgType(any) error
|
||||
}
|
||||
|
||||
// preparedMsg contains the marshalled success response messages
|
||||
// preparedMsg contains the marshalled success response message
|
||||
type preparedMsg struct {
|
||||
responseHelloMsg []byte
|
||||
responseAuthMsg []byte
|
||||
responseAuthMsg []byte
|
||||
}
|
||||
|
||||
func newPreparedMsg(instanceURL string) (*preparedMsg, error) {
|
||||
rhm, err := marshalResponseHelloMsg(instanceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ram, err := messages.MarshalAuthResponse(instanceURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal auth response msg: %w", err)
|
||||
}
|
||||
|
||||
return &preparedMsg{
|
||||
responseHelloMsg: rhm,
|
||||
responseAuthMsg: ram,
|
||||
responseAuthMsg: ram,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func marshalResponseHelloMsg(instanceURL string) ([]byte, error) {
|
||||
addr := &address.Address{URL: instanceURL}
|
||||
addrData, err := addr.Marshal()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal response address: %w", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
responseMsg, err := messages.MarshalHelloResponse(addrData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal hello response: %w", err)
|
||||
}
|
||||
return responseMsg, nil
|
||||
}
|
||||
|
||||
type handshake struct {
|
||||
conn listener.Conn
|
||||
validator Validator
|
||||
preparedMsg *preparedMsg
|
||||
|
||||
handshakeMethodAuth bool
|
||||
peerID *messages.PeerID
|
||||
peerID *messages.PeerID
|
||||
}
|
||||
|
||||
func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) {
|
||||
@@ -93,17 +62,11 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err
|
||||
return nil, fmt.Errorf("determine message type from %s: %w", h.conn.RemoteAddr(), err)
|
||||
}
|
||||
|
||||
var peerID *messages.PeerID
|
||||
switch msgType {
|
||||
//nolint:staticcheck
|
||||
case messages.MsgTypeHello:
|
||||
peerID, err = h.handleHelloMsg(buf)
|
||||
case messages.MsgTypeAuth:
|
||||
h.handshakeMethodAuth = true
|
||||
peerID, err = h.handleAuthMsg(buf)
|
||||
default:
|
||||
if msgType != messages.MsgTypeAuth {
|
||||
return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr())
|
||||
}
|
||||
|
||||
peerID, err := h.handleAuthMsg(buf)
|
||||
if err != nil {
|
||||
return peerID, err
|
||||
}
|
||||
@@ -112,46 +75,17 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err
|
||||
}
|
||||
|
||||
func (h *handshake) handshakeResponse(ctx context.Context) error {
|
||||
var responseMsg []byte
|
||||
if h.handshakeMethodAuth {
|
||||
responseMsg = h.preparedMsg.responseAuthMsg
|
||||
} else {
|
||||
responseMsg = h.preparedMsg.responseHelloMsg
|
||||
}
|
||||
|
||||
if _, err := h.conn.Write(ctx, responseMsg); err != nil {
|
||||
if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil {
|
||||
return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handshake) handleHelloMsg(buf []byte) (*messages.PeerID, error) {
|
||||
//nolint:staticcheck
|
||||
peerID, authData, err := messages.UnmarshalHelloMsg(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hello message: %w", err)
|
||||
}
|
||||
|
||||
log.Warnf("peer %s (%s) is using deprecated initial message type", peerID, h.conn.RemoteAddr())
|
||||
|
||||
authMsg, err := authmsg.UnmarshalMsg(authData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshal auth message: %w", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
if err := h.validator.ValidateHelloMsgType(authMsg.AdditionalData); err != nil {
|
||||
return nil, fmt.Errorf("validate %s (%s): %w", peerID, h.conn.RemoteAddr(), err)
|
||||
}
|
||||
|
||||
return peerID, nil
|
||||
}
|
||||
|
||||
func (h *handshake) handleAuthMsg(buf []byte) (*messages.PeerID, error) {
|
||||
rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hello message: %w", err)
|
||||
return nil, fmt.Errorf("unmarshal auth message: %w", err)
|
||||
}
|
||||
|
||||
if err := h.validator.Validate(authPayload); err != nil {
|
||||
|
||||
@@ -51,7 +51,10 @@ func (l *Listener) Listen(acceptFn func(conn relaylistener.Conn)) error {
|
||||
|
||||
log.Infof("QUIC client connected from: %s", session.RemoteAddr())
|
||||
conn := NewConn(session)
|
||||
acceptFn(conn)
|
||||
// Run the accept handler (which performs the pre-auth handshake) in its
|
||||
// own goroutine so a slow or stalled handshake cannot block accepting
|
||||
// further connections.
|
||||
go acceptFn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user