mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 22:09:08 +01:00
Merge branch 'main' into feature/dns-lazy-conn-warmup
This commit is contained in:
@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
|||||||
deps.SyncResponse = resp
|
deps.SyncResponse = resp
|
||||||
|
|
||||||
if e := cc.Engine(); e != nil {
|
if e := cc.Engine(); e != nil {
|
||||||
|
deps.RefreshStatus = func() {
|
||||||
|
e.RunHealthProbes(context.Background(), true)
|
||||||
|
}
|
||||||
if cm := e.GetClientMetrics(); cm != nil {
|
if cm := e.GetClientMetrics(); cm != nil {
|
||||||
deps.ClientMetrics = cm
|
deps.ClientMetrics = cm
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ type Manager struct {
|
|||||||
udpTracker *conntrack.UDPTracker
|
udpTracker *conntrack.UDPTracker
|
||||||
icmpTracker *conntrack.ICMPTracker
|
icmpTracker *conntrack.ICMPTracker
|
||||||
tcpTracker *conntrack.TCPTracker
|
tcpTracker *conntrack.TCPTracker
|
||||||
|
fragments *fragmentTracker
|
||||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||||
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
||||||
logger *nblog.Logger
|
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
|
// Create userspace firewall manager constructor
|
||||||
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
||||||
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
|
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 {
|
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
|
||||||
return nil, fmt.Errorf("update local IPs: %w", err)
|
return nil, fmt.Errorf("update local IPs: %w", err)
|
||||||
}
|
}
|
||||||
|
m.fragments = newFragmentTracker(m.logger)
|
||||||
|
|
||||||
if disableConntrack {
|
if disableConntrack {
|
||||||
log.Info("conntrack is disabled")
|
log.Info("conntrack is disabled")
|
||||||
} else {
|
} else {
|
||||||
@@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := iface.SetFilter(m); err != nil {
|
if err := iface.SetFilter(m); err != nil {
|
||||||
|
m.fragments.Close()
|
||||||
return nil, fmt.Errorf("set filter: %w", err)
|
return nil, fmt.Errorf("set filter: %w", err)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
|
|||||||
m.tcpTracker.Close()
|
m.tcpTracker.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.fragments != nil {
|
||||||
|
m.fragments.Close()
|
||||||
|
}
|
||||||
|
|
||||||
if fwder := m.forwarder.Load(); fwder != nil {
|
if fwder := m.forwarder.Load(); fwder != nil {
|
||||||
fwder.SetCapture(nil)
|
fwder.SetCapture(nil)
|
||||||
fwder.Stop()
|
fwder.Stop()
|
||||||
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
|||||||
return true
|
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 fragment {
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
return m.filterInboundFragment(d, srcIP, dstIP, size)
|
||||||
if d.decoded[0] == layers.LayerTypeIPv4 {
|
|
||||||
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
|
||||||
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
|
||||||
} else {
|
|
||||||
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
|
||||||
|
// decoded (non-fragment) inbound packet. It returns true if the packet should
|
||||||
|
// be dropped.
|
||||||
|
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||||
// TODO: optimize port DNAT by caching matched rules in conntrack
|
// TODO: optimize port DNAT by caching matched rules in conntrack
|
||||||
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
||||||
// Re-decode after port DNAT translation to update port information
|
// 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)
|
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.
|
// handleLocalTraffic handles local traffic.
|
||||||
// If it returns true, the packet should be dropped.
|
// If it returns true, the packet should be dropped.
|
||||||
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
||||||
if blocked {
|
if blocked {
|
||||||
pnum := getProtocolFromPacket(d)
|
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
srcPort, dstPort := getPortsFromPacket(d)
|
d, srcIP, dstIP, ruleID, size)
|
||||||
|
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
|
||||||
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
|
||||||
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: nftypes.TypeDrop,
|
|
||||||
RuleID: ruleID,
|
|
||||||
Direction: nftypes.Ingress,
|
|
||||||
Protocol: pnum,
|
|
||||||
SourceIP: srcIP,
|
|
||||||
DestIP: dstIP,
|
|
||||||
SourcePort: srcPort,
|
|
||||||
DestPort: dstPort,
|
|
||||||
// TODO: icmp type/code
|
|
||||||
RxPackets: 1,
|
|
||||||
RxBytes: uint64(size),
|
|
||||||
})
|
|
||||||
return true
|
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)
|
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
||||||
if !pass {
|
if !pass {
|
||||||
proto := getProtocolFromPacket(d)
|
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
d, srcIP, dstIP, ruleID, size)
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
|
||||||
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
|
||||||
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: nftypes.TypeDrop,
|
|
||||||
RuleID: ruleID,
|
|
||||||
Direction: nftypes.Ingress,
|
|
||||||
Protocol: proto,
|
|
||||||
SourceIP: srcIP,
|
|
||||||
DestIP: dstIP,
|
|
||||||
SourcePort: srcPort,
|
|
||||||
DestPort: dstPort,
|
|
||||||
// TODO: icmp type/code
|
|
||||||
RxPackets: 1,
|
|
||||||
RxBytes: uint64(size),
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +33,11 @@ const (
|
|||||||
defaultMaxInFlight = 1024
|
defaultMaxInFlight = 1024
|
||||||
iosReceiveWindow = 16384
|
iosReceiveWindow = 16384
|
||||||
iosMaxInFlight = 256
|
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 {
|
type Forwarder struct {
|
||||||
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
|||||||
maxInFlight = iosMaxInFlight
|
maxInFlight = iosMaxInFlight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureTCPRecovery(s)
|
||||||
|
|
||||||
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
||||||
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
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)
|
logger.Debug1("forwarder: raw %s socket access available", network)
|
||||||
return true
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package netstack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
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
|
// IsEnabled todo: move these function to cmd layer
|
||||||
func IsEnabled() bool {
|
func IsEnabled() bool {
|
||||||
@@ -18,24 +35,40 @@ func IsEnabled() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListenAddr() string {
|
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 == "" {
|
if sPort == "" {
|
||||||
return listenAddr(DefaultSocks5Port)
|
return DefaultSocks5Port
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := strconv.Atoi(sPort)
|
port, err := strconv.Atoi(sPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
|
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 {
|
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)
|
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)
|
return port
|
||||||
}
|
|
||||||
|
|
||||||
func listenAddr(port int) string {
|
|
||||||
return fmt.Sprintf("0.0.0.0:%d", port)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
UseIDToken: d.providerConfig.UseIDToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
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
|
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)
|
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
|
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) {
|
func parseEmailFromIDToken(token string) (string, error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|||||||
@@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidAccessToken is a simple validation of the access token
|
// validateTokenAudience checks that the token is a well-formed JWT whose
|
||||||
func isValidAccessToken(token string, audience string) error {
|
// 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 == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("token received is empty")
|
return fmt.Errorf("token received is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedClaims := strings.Split(token, ".")[1]
|
parts := strings.Split(token, ".")
|
||||||
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
if len(parts) != 3 {
|
||||||
|
return fmt.Errorf("token is not a well-formed JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
|||||||
return customPort, nil
|
return customPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||||
err = probeListener.Close()
|
if err = probeListener.Close(); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Debugf("failed to free up DNS port: %s", err)
|
log.Debugf("failed to free up DNS port: %s", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return addrPort.Port(), nil
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) update(ctx context.Context) error {
|
func (r *Route) update(ctx context.Context) error {
|
||||||
resolved, err := r.resolveDomains()
|
resolved, err := r.resolveDomains(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(resolved) == 0 {
|
if len(resolved) == 0 {
|
||||||
return fmt.Errorf("resolve domains: %w", err)
|
return fmt.Errorf("resolve domains: %w", err)
|
||||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) resolveDomains() (domainMap, error) {
|
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||||
results := make(chan resolveResult)
|
results := make(chan resolveResult)
|
||||||
go r.resolve(results)
|
go r.resolve(ctx, results)
|
||||||
|
|
||||||
resolved := domainMap{}
|
resolved := domainMap{}
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
|
|||||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) resolve(results chan resolveResult) {
|
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, d := range r.route.Domains {
|
for _, d := range r.route.Domains {
|
||||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
|
|||||||
go func(domain domain.Domain) {
|
go func(domain domain.Domain) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
ips, err := r.getIPsFromResolver(domain)
|
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||||
ips, err = net.LookupIP(domain.PunycodeString())
|
ips, err = lookupHostIPs(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||||
return
|
return
|
||||||
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||||
|
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := make([]net.IP, 0, len(addrs))
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ips = append(ips, addr.IP)
|
||||||
|
}
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||||
prefixSet := make(map[netip.Prefix]struct{})
|
prefixSet := make(map[netip.Prefix]struct{})
|
||||||
for _, prefix := range oldPrefixes {
|
for _, prefix := range oldPrefixes {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
package dynamic
|
package dynamic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||||
return net.LookupIP(domain.PunycodeString())
|
return lookupHostIPs(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package dynamic
|
package dynamic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
|
|
||||||
const dialTimeout = 10 * time.Second
|
const dialTimeout = 10 * time.Second
|
||||||
|
|
||||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||||
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
|||||||
msg := new(dns.Msg)
|
msg := new(dns.Msg)
|
||||||
msg.SetQuestion(fqdn, qtype)
|
msg.SetQuestion(fqdn, qtype)
|
||||||
|
|
||||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if queryErr == nil {
|
if queryErr == nil {
|
||||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package statemanager
|
package statemanager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
|
|||||||
|
|
||||||
var rawStates map[string]json.RawMessage
|
var rawStates map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(data, &rawStates); err != nil {
|
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)
|
m.handleCorruptedState(deleteCorrupt)
|
||||||
return nil, fmt.Errorf("unmarshal states: %w", err)
|
return nil, fmt.Errorf("unmarshal states: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
|||||||
deps.SyncResponse = resp
|
deps.SyncResponse = resp
|
||||||
|
|
||||||
if e := cc.Engine(); e != nil {
|
if e := cc.Engine(); e != nil {
|
||||||
|
deps.RefreshStatus = func() {
|
||||||
|
e.RunHealthProbes(context.Background(), true)
|
||||||
|
}
|
||||||
if cm := e.GetClientMetrics(); cm != nil {
|
if cm := e.GetClientMetrics(); cm != nil {
|
||||||
deps.ClientMetrics = cm
|
deps.ClientMetrics = cm
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,25 @@ type Auth struct {
|
|||||||
// NewAuth instantiate Auth struct and validate the management URL
|
// NewAuth instantiate Auth struct and validate the management URL
|
||||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||||
inputCfg := profilemanager.ConfigInput{
|
inputCfg := profilemanager.ConfigInput{
|
||||||
|
ConfigPath: cfgPath,
|
||||||
ManagementURL: mgmURL,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1081,7 +1081,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
|||||||
|
|
||||||
if err := s.cleanupConnection(); err != nil {
|
if err := s.cleanupConnection(); err != nil {
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
// todo review to update the status in case any type of error
|
if errors.Is(err, ErrServiceNotUp) {
|
||||||
|
log.Debugf("Down called while service not up: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
log.Errorf("failed to shut down properly: %v", err)
|
log.Errorf("failed to shut down properly: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1157,7 @@ func (s *Server) cleanupConnection() error {
|
|||||||
// making the run loop the sole owner of engine shutdown.
|
// making the run loop the sole owner of engine shutdown.
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
if err := engine.Stop(); err != nil {
|
if err := engine.Stop(); err != nil {
|
||||||
return err
|
log.Errorf("failed to stop engine during cleanup: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
|
|||||||
if peer.IPv6.IsValid() {
|
if peer.IPv6.IsValid() {
|
||||||
hostPatterns = append(hostPatterns, peer.IPv6.String())
|
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)
|
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)
|
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
|
return hostPatterns
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) {
|
|||||||
"should use Match host with comma-separated patterns")
|
"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) {
|
func TestManager_ForcedSSHConfig(t *testing.T) {
|
||||||
// Set force environment variable
|
// Set force environment variable
|
||||||
t.Setenv(EnvForceSSHConfig, "true")
|
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.
|
// validateGetentInput checks that the input is safe to pass to getent or id.
|
||||||
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
|
// 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 {
|
func validateGetentInput(input string) bool {
|
||||||
maxLen := 32
|
maxLen := 32
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
@@ -80,6 +81,10 @@ func validateGetentInput(input string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input[0] == '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for _, r := range input {
|
for _, r := range input {
|
||||||
if isAllowedGetentChar(r) {
|
if isAllowedGetentChar(r) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) {
|
|||||||
{"numeric UID", "1001", true},
|
{"numeric UID", "1001", true},
|
||||||
{"dots and underscores", "alice.bob_test", true},
|
{"dots and underscores", "alice.bob_test", true},
|
||||||
{"hyphen", "alice-bob", 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},
|
{"kerberos principal", "user@REALM", true},
|
||||||
{"samba machine account", "MACHINE$", true},
|
{"samba machine account", "MACHINE$", true},
|
||||||
{"NIS compat", "+user", true},
|
{"NIS compat", "+user", true},
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||||
import { Browser } from "@wailsio/runtime";
|
import { Browser } from "@wailsio/runtime";
|
||||||
|
import { Version } from "@bindings/services";
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Button } from "@/components/buttons/Button";
|
||||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||||
|
|
||||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||||
|
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
|
||||||
|
|
||||||
function openUrl(url: string) {
|
function openUrl(url: string) {
|
||||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||||
@@ -12,7 +15,26 @@ function openUrl(url: string) {
|
|||||||
|
|
||||||
export const DaemonOutdatedOverlay = () => {
|
export const DaemonOutdatedOverlay = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDaemonOutdated } = useStatus();
|
const { status, isDaemonOutdated } = useStatus();
|
||||||
|
|
||||||
|
const [guiVersion, setGuiVersion] = useState<string>("-");
|
||||||
|
const clientVersion = status?.daemonVersion ?? "—";
|
||||||
|
|
||||||
|
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
|
||||||
|
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDaemonOutdated) return;
|
||||||
|
let cancelled = false;
|
||||||
|
Version.GUI()
|
||||||
|
.then((v) => {
|
||||||
|
if (!cancelled) setGuiVersion(v);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isDaemonOutdated]);
|
||||||
|
|
||||||
if (!isDaemonOutdated) return null;
|
if (!isDaemonOutdated) return null;
|
||||||
|
|
||||||
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
|
|||||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||||
|
<p className={"text-sm font-semibold text-nb-gray-100"}>
|
||||||
|
{clientVersion === "development" ? (
|
||||||
|
<span>
|
||||||
|
{t("settings.about.clientName")}{" "}
|
||||||
|
<span className={"font-mono text-yellow-400"}>
|
||||||
|
{t("settings.about.development")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("settings.about.client", { version: clientVersion })
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className={"text-sm font-medium text-nb-gray-250"}>
|
||||||
|
{guiVersion === "development" ? (
|
||||||
|
<span>
|
||||||
|
{t("settings.about.guiName")}{" "}
|
||||||
|
<span className={"font-mono text-yellow-400"}>
|
||||||
|
{t("settings.about.development")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("settings.about.gui", { version: guiVersion })
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={"wails-no-draggable"}>
|
<div className={"wails-no-draggable"}>
|
||||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
|
||||||
<DownloadIcon size={14} />
|
<DownloadIcon size={14} />
|
||||||
{t("update.card.getInstaller")}
|
{t("daemon.outdated.download")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type ProfileContextValue = {
|
|||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
switchProfile: (id: string) => Promise<void>;
|
switchProfile: (id: string) => Promise<void>;
|
||||||
|
switchProfileNoConnect: (id: string) => Promise<void>;
|
||||||
addProfile: (name: string) => Promise<string>;
|
addProfile: (name: string) => Promise<string>;
|
||||||
removeProfile: (id: string) => Promise<void>;
|
removeProfile: (id: string) => Promise<void>;
|
||||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||||
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
[username, refresh],
|
[username, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Manage-profiles variant: switches without connecting, so the user can
|
||||||
|
// still adjust the management URL before bringing the connection up.
|
||||||
|
const switchProfileNoConnect = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[username, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
// addProfile creates a profile by display name and returns the
|
// addProfile creates a profile by display name and returns the
|
||||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||||
const addProfile = useCallback(
|
const addProfile = useCallback(
|
||||||
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
loaded,
|
loaded,
|
||||||
refresh,
|
refresh,
|
||||||
switchProfile,
|
switchProfile,
|
||||||
|
switchProfileNoConnect,
|
||||||
addProfile,
|
addProfile,
|
||||||
removeProfile,
|
removeProfile,
|
||||||
renameProfile,
|
renameProfile,
|
||||||
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
loaded,
|
loaded,
|
||||||
refresh,
|
refresh,
|
||||||
switchProfile,
|
switchProfile,
|
||||||
|
switchProfileNoConnect,
|
||||||
addProfile,
|
addProfile,
|
||||||
removeProfile,
|
removeProfile,
|
||||||
renameProfile,
|
renameProfile,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ProfilesTab() {
|
|||||||
activeProfileId,
|
activeProfileId,
|
||||||
loaded,
|
loaded,
|
||||||
username,
|
username,
|
||||||
switchProfile,
|
switchProfileNoConnect,
|
||||||
addProfile,
|
addProfile,
|
||||||
removeProfile,
|
removeProfile,
|
||||||
renameProfile,
|
renameProfile,
|
||||||
@@ -100,7 +100,7 @@ export function ProfilesTab() {
|
|||||||
confirmLabel: t("profile.switch.confirm"),
|
confirmLabel: t("profile.switch.confirm"),
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeregister = async (id: string, name: string) => {
|
const handleDeregister = async (id: string, name: string) => {
|
||||||
@@ -129,14 +129,13 @@ export function ProfilesTab() {
|
|||||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||||
const id = await addProfile(name);
|
const id = await addProfile(name);
|
||||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||||
// not-yet-active profile. Write before switching so any reconnect
|
// not-yet-active profile before the switch makes it current.
|
||||||
// targets the right deployment.
|
|
||||||
if (!isNetbirdCloud(managementUrl)) {
|
if (!isNetbirdCloud(managementUrl)) {
|
||||||
await SettingsSvc.SetConfig(
|
await SettingsSvc.SetConfig(
|
||||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await switchProfile(id);
|
await switchProfileNoConnect(id);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
|
|||||||
|
|
||||||
let offCancel: (() => void) | undefined;
|
let offCancel: (() => void) | undefined;
|
||||||
|
|
||||||
|
// Return the dialog to its interactive state and dismiss the browser popup
|
||||||
|
const resetDialog = () => {
|
||||||
|
offCancel?.();
|
||||||
|
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const start = await Session.RequestExtend({ hint: "" });
|
const start = await Session.RequestExtend({ hint: "" });
|
||||||
const uri = start.verificationUriComplete || start.verificationUri;
|
const uri = start.verificationUriComplete || start.verificationUri;
|
||||||
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
|
|||||||
if (outcome.kind === "cancel") {
|
if (outcome.kind === "cancel") {
|
||||||
waitPromise.cancel?.();
|
waitPromise.cancel?.();
|
||||||
waitPromise.catch(() => {});
|
waitPromise.catch(() => {});
|
||||||
|
resetDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Another surface owns this flow; keep the dialog open to retry.
|
// Another surface owns this flow; keep the dialog open to retry.
|
||||||
if (outcome.result.preempted) {
|
if (outcome.result.preempted) {
|
||||||
|
resetDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
WindowManager.CloseRenewFlow().catch(console.error);
|
||||||
// Close before the popup so the restore can't flash this window back.
|
|
||||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
resetDialog();
|
||||||
await errorDialog({
|
await errorDialog({
|
||||||
Title: t("sessionExpiration.extendFailedTitle"),
|
Title: t("sessionExpiration.extendFailedTitle"),
|
||||||
Message: formatErrorMessage(e),
|
Message: formatErrorMessage(e),
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
offCancel?.();
|
|
||||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
}, [busy, t]);
|
}, [busy, t]);
|
||||||
|
|
||||||
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
|
|||||||
});
|
});
|
||||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setBusy(false);
|
||||||
await errorDialog({
|
await errorDialog({
|
||||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||||
Message: formatErrorMessage(e),
|
Message: formatErrorMessage(e),
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
}, [busy, t]);
|
}, [busy, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
|
|||||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const trayScreenshot = trayScreenshotForOS();
|
const trayScreenshot = trayScreenshotForOS();
|
||||||
|
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
|
||||||
|
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
|
||||||
|
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
|||||||
|
|
||||||
<div className={"flex w-full flex-col gap-1"}>
|
<div className={"flex w-full flex-col gap-1"}>
|
||||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||||
{t("welcome.title")}
|
{t(titleKey)}
|
||||||
</DialogHeading>
|
</DialogHeading>
|
||||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{"code": "fr", "displayName": "Français", "englishName": "French"},
|
{"code": "fr", "displayName": "Français", "englishName": "French"},
|
||||||
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
|
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
|
||||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
{"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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Suchen Sie NetBird in der Taskleiste"
|
"message": "Suchen Sie NetBird in der Taskleiste"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Suchen Sie NetBird in der Menüleiste"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Weiter"
|
"message": "Weiter"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Dokumentation"
|
"message": "Dokumentation"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "NetBird-Dienst ist veraltet"
|
"message": "NetBird Client ist veraltet"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
|
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Neueste Version herunterladen"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
||||||
|
|||||||
@@ -1377,11 +1377,19 @@
|
|||||||
},
|
},
|
||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Look for NetBird in your tray",
|
"message": "Look for NetBird in your tray",
|
||||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
|
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
|
||||||
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Look for NetBird in your menu bar",
|
||||||
|
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||||
},
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||||
"description": "Body of the first onboarding step explaining the tray icon."
|
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
|
||||||
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
|
||||||
|
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||||
},
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Continue",
|
"message": "Continue",
|
||||||
@@ -1724,12 +1732,16 @@
|
|||||||
"description": "Documentation link on the daemon-unavailable overlay."
|
"description": "Documentation link on the daemon-unavailable overlay."
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "NetBird Service Is Outdated",
|
"message": "NetBird Client Is Outdated",
|
||||||
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
|
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Update the NetBird service to use this app.",
|
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
|
||||||
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
|
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Download Latest",
|
||||||
|
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Busque NetBird en su bandeja del sistema"
|
"message": "Busque NetBird en su bandeja del sistema"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Busque NetBird en su barra de menús"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Continuar"
|
"message": "Continuar"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Documentación"
|
"message": "Documentación"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "El servicio de NetBird está desactualizado"
|
"message": "NetBird Client está desactualizado"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Actualice el servicio de NetBird para usar esta aplicación."
|
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Descargar la última versión"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Cherchez NetBird dans votre barre d’état système"
|
"message": "Cherchez NetBird dans votre barre d’état système"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Cherchez NetBird dans votre barre des menus"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Continuer"
|
"message": "Continuer"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Documentation"
|
"message": "Documentation"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "Le service NetBird est obsolète"
|
"message": "Le Client NetBird est obsolète"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Mettez à jour le service NetBird pour utiliser cette application."
|
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Télécharger la dernière version"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Keresse a NetBirdöt a tálcán"
|
"message": "Keresse a NetBirdöt a tálcán"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Keresse a NetBirdöt a menüsorban"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Folytatás"
|
"message": "Folytatás"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Dokumentáció"
|
"message": "Dokumentáció"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "A NetBird szolgáltatás elavult"
|
"message": "A NetBird Kliens elavult"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
|
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Legújabb letöltése"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Cerchi NetBird nella tray"
|
"message": "Cerchi NetBird nella tray"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Cerchi NetBird nella barra dei menu"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Continua"
|
"message": "Continua"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Documentazione"
|
"message": "Documentazione"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "Il servizio NetBird è obsoleto"
|
"message": "NetBird Client è obsoleto"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Aggiorna il servizio NetBird per usare questa app."
|
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Scarica l'ultima versione"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Procure o NetBird na sua bandeja"
|
"message": "Procure o NetBird na sua bandeja"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Procure o NetBird na sua barra de menus"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Continuar"
|
"message": "Continuar"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Documentação"
|
"message": "Documentação"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "O serviço NetBird está desatualizado"
|
"message": "O NetBird Client está desatualizado"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Atualize o serviço NetBird para usar este aplicativo."
|
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Baixar a versão mais recente"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."
|
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "Найдите NetBird в системном трее"
|
"message": "Найдите NetBird в системном трее"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "Найдите NetBird в строке меню"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "Продолжить"
|
"message": "Продолжить"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "Документация"
|
"message": "Документация"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "Служба NetBird устарела"
|
"message": "Клиент NetBird устарел"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "Обновите службу NetBird, чтобы использовать это приложение."
|
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "Скачать последнюю версию"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."
|
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."
|
||||||
|
|||||||
@@ -1034,9 +1034,15 @@
|
|||||||
"welcome.title": {
|
"welcome.title": {
|
||||||
"message": "在托盘中查找 NetBird"
|
"message": "在托盘中查找 NetBird"
|
||||||
},
|
},
|
||||||
|
"welcome.titleMac": {
|
||||||
|
"message": "在菜单栏中查找 NetBird"
|
||||||
|
},
|
||||||
"welcome.description": {
|
"welcome.description": {
|
||||||
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
|
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
|
||||||
},
|
},
|
||||||
|
"welcome.descriptionMac": {
|
||||||
|
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
|
||||||
|
},
|
||||||
"welcome.continue": {
|
"welcome.continue": {
|
||||||
"message": "继续"
|
"message": "继续"
|
||||||
},
|
},
|
||||||
@@ -1293,10 +1299,13 @@
|
|||||||
"message": "文档"
|
"message": "文档"
|
||||||
},
|
},
|
||||||
"daemon.outdated.title": {
|
"daemon.outdated.title": {
|
||||||
"message": "NetBird 服务版本过旧"
|
"message": "NetBird 客户端版本过旧"
|
||||||
},
|
},
|
||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "请更新 NetBird 服务以使用此应用。"
|
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。"
|
||||||
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "下载最新版本"
|
||||||
},
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"
|
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProfileSwitcher holds the reconnect policy shared by the tray and React
|
// ProfileSwitcher holds the switch policy shared by the tray and React
|
||||||
// frontend so both flip profiles identically. The policy keys off prevStatus
|
// frontend so both flip profiles identically. SwitchActive (plain selection:
|
||||||
// from DaemonFeed.Get at SwitchActive entry:
|
// header dropdown, tray submenu) always connects after the switch;
|
||||||
|
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
|
||||||
|
// still adjust the management URL before connecting. prevStatus from
|
||||||
|
// DaemonFeed.Get at entry only decides the teardown:
|
||||||
//
|
//
|
||||||
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
|
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
|
||||||
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
|
// Idle → no Down.
|
||||||
// Idle → Switch only.
|
|
||||||
type ProfileSwitcher struct {
|
type ProfileSwitcher struct {
|
||||||
profiles *Profiles
|
profiles *Profiles
|
||||||
connection *Connection
|
connection *Connection
|
||||||
@@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
|
|||||||
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchActive switches to the named profile applying the reconnect policy.
|
// SwitchActive switches to the named profile and always connects afterwards.
|
||||||
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||||
|
return s.switchActive(ctx, p, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchActiveNoConnect switches to the named profile without connecting,
|
||||||
|
// tearing down any existing connection first.
|
||||||
|
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
|
||||||
|
return s.switchActive(ctx, p, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
|
||||||
prevStatus := ""
|
prevStatus := ""
|
||||||
if st, err := s.feed.Get(ctx); err == nil {
|
if s.feed != nil {
|
||||||
prevStatus = st.Status
|
if st, err := s.feed.Get(ctx); err == nil {
|
||||||
} else {
|
prevStatus = st.Status
|
||||||
log.Warnf("profileswitcher: get status: %v", err)
|
} else {
|
||||||
|
log.Warnf("profileswitcher: get status: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
|
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||||
strings.EqualFold(prevStatus, StatusConnecting)
|
strings.EqualFold(prevStatus, StatusConnecting) ||
|
||||||
needsDown := wasActive ||
|
|
||||||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
||||||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
||||||
strings.EqualFold(prevStatus, StatusSessionExpired)
|
strings.EqualFold(prevStatus, StatusSessionExpired)
|
||||||
|
|
||||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
|
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
|
||||||
p.ProfileName, prevStatus, wasActive, needsDown)
|
p.ProfileName, prevStatus, connect, needsDown)
|
||||||
|
|
||||||
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
|
// Optimistic Connecting paint plus stale-push suppression during Down (see
|
||||||
// stale Connected + transient Idle pushes during Down that must be
|
// DaemonFeed suppression table); also arms the login-watch that pops
|
||||||
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
|
// browser-login when the new profile turns out to need SSO.
|
||||||
if wasActive {
|
if connect && s.feed != nil {
|
||||||
s.feed.BeginProfileSwitch()
|
s.feed.BeginProfileSwitch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +89,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if wasActive {
|
if connect {
|
||||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||||
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
|
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,37 +185,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
|||||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||||
}
|
}
|
||||||
s.hideOtherWindowsLocked("browser-login")
|
s.hideOtherWindowsLocked("browser-login")
|
||||||
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
|
|
||||||
var screen *application.Screen
|
|
||||||
if s.mainWindow != nil {
|
|
||||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
|
||||||
screen = sc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||||
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||||
opts.AlwaysOnTop = false
|
opts.AlwaysOnTop = false
|
||||||
opts.InitialPosition = application.WindowCentered
|
opts.InitialPosition = application.WindowCentered
|
||||||
opts.Screen = screen
|
// Open on the active (where users cursor is) display, like the session-expiration dialog.
|
||||||
|
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||||
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
||||||
bl := s.browserLogin
|
bl := s.browserLogin
|
||||||
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
|
|
||||||
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.browserLogin = nil
|
// Only a live user red-X still has this registered; programmatic closers
|
||||||
s.restoreHiddenWindowsLocked()
|
// nil s.browserLogin first and clean up themselves. Guarding here stops a
|
||||||
|
// stale close event from wiping a replacement popup's state.
|
||||||
|
userClosed := s.browserLogin == bl
|
||||||
|
if userClosed {
|
||||||
|
s.browserLogin = nil
|
||||||
|
s.restoreHiddenWindowsLocked()
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if userClosed {
|
||||||
|
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
s.centerWhenReady(s.browserLogin)
|
s.centerOnCursorScreen(s.browserLogin)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if uri != "" {
|
if uri != "" {
|
||||||
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
||||||
}
|
}
|
||||||
|
s.centerOnCursorScreen(s.browserLogin)
|
||||||
s.browserLogin.Show()
|
s.browserLogin.Show()
|
||||||
s.browserLogin.Focus()
|
s.browserLogin.Focus()
|
||||||
s.centerWhenReady(s.browserLogin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
||||||
@@ -238,6 +239,15 @@ func (s *WindowManager) CloseBrowserLogin() {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
w := s.browserLogin
|
w := s.browserLogin
|
||||||
s.browserLogin = nil
|
s.browserLogin = nil
|
||||||
|
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
||||||
|
// but only if a popup was actually open. The frontend calls this even when no
|
||||||
|
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
||||||
|
// or connection.ts's catch path), and hiddenForLogin is shared with
|
||||||
|
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
||||||
|
// still-running install-progress is hiding.
|
||||||
|
if w != nil {
|
||||||
|
s.restoreHiddenWindowsLocked()
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if w != nil {
|
if w != nil {
|
||||||
w.Close()
|
w.Close()
|
||||||
@@ -279,6 +289,35 @@ func (s *WindowManager) CloseSessionExpiration() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
|
||||||
|
// closes the browser-login popup and the session-expiration window together.
|
||||||
|
func (s *WindowManager) CloseRenewFlow() {
|
||||||
|
s.mu.Lock()
|
||||||
|
bl := s.browserLogin
|
||||||
|
se := s.sessionExpiration
|
||||||
|
s.browserLogin = nil
|
||||||
|
s.sessionExpiration = nil
|
||||||
|
if se != nil {
|
||||||
|
kept := s.hiddenForLogin[:0]
|
||||||
|
for _, w := range s.hiddenForLogin {
|
||||||
|
if w != se {
|
||||||
|
kept = append(kept, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.hiddenForLogin = kept
|
||||||
|
}
|
||||||
|
s.restoreHiddenWindowsLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||||
|
if bl != nil {
|
||||||
|
bl.Close()
|
||||||
|
}
|
||||||
|
if se != nil {
|
||||||
|
se.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
||||||
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
||||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||||
|
|||||||
+20
-1
@@ -30,6 +30,8 @@ const (
|
|||||||
|
|
||||||
statusError = "Error"
|
statusError = "Error"
|
||||||
|
|
||||||
|
quitDownTimeout = 5 * time.Second
|
||||||
|
|
||||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||||
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
||||||
urlDocs = "https://docs.netbird.io"
|
urlDocs = "https://docs.netbird.io"
|
||||||
@@ -446,11 +448,28 @@ func (t *Tray) buildMenu() *application.Menu {
|
|||||||
menu.AddSeparator()
|
menu.AddSeparator()
|
||||||
menu.Add(t.loc.T("tray.menu.quit")).
|
menu.Add(t.loc.T("tray.menu.quit")).
|
||||||
SetAccelerator("CmdOrCtrl+Q").
|
SetAccelerator("CmdOrCtrl+Q").
|
||||||
OnClick(func(*application.Context) { t.app.Quit() })
|
OnClick(func(*application.Context) { t.handleQuit() })
|
||||||
|
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tray) handleQuit() {
|
||||||
|
t.profileMu.Lock()
|
||||||
|
if t.switchCancel != nil {
|
||||||
|
t.switchCancel()
|
||||||
|
t.switchCancel = nil
|
||||||
|
}
|
||||||
|
t.profileMu.Unlock()
|
||||||
|
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||||
|
log.Errorf("disconnect on quit: %v", err)
|
||||||
|
}
|
||||||
|
t.app.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
// handleConnect receives the clicked item from the buildMenu closure —
|
// handleConnect receives the clicked item from the buildMenu closure —
|
||||||
// t.upItem is menuMu-guarded and must not be read here.
|
// t.upItem is menuMu-guarded and must not be read here.
|
||||||
func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ type AuthConfig struct {
|
|||||||
CLIRedirectURIs []string `yaml:"cliRedirectURIs"`
|
CLIRedirectURIs []string `yaml:"cliRedirectURIs"`
|
||||||
Owner *AuthOwnerConfig `yaml:"owner,omitempty"`
|
Owner *AuthOwnerConfig `yaml:"owner,omitempty"`
|
||||||
DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"`
|
DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"`
|
||||||
|
GrantTypes []string `yaml:"grantTypes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthStorageConfig contains auth storage settings
|
// AuthStorageConfig contains auth storage settings
|
||||||
@@ -604,6 +605,7 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
|
|||||||
DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs,
|
DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs,
|
||||||
CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs,
|
CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs,
|
||||||
DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs,
|
DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs,
|
||||||
|
GrantTypes: mgmt.Auth.GrantTypes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" {
|
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))
|
hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret))
|
||||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
|
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
|
||||||
|
|
||||||
relayCfg := relayServer.Config{
|
relayCfg := relayServer.Config{
|
||||||
Meter: s.metricsServer.Meter,
|
Meter: s.metricsServer.Meter,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ require (
|
|||||||
github.com/ti-mo/conntrack v0.5.1
|
github.com/ti-mo/conntrack v0.5.1
|
||||||
github.com/ti-mo/netfilter v0.5.2
|
github.com/ti-mo/netfilter v0.5.2
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
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/yusufpapurcu/wmi v1.2.4
|
||||||
github.com/zcalusic/sysinfo v1.1.3
|
github.com/zcalusic/sysinfo v1.1.3
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
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/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // 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/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.3 // 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/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
|
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||||
|
|
||||||
|
|||||||
@@ -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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0=
|
||||||
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/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 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88=
|
||||||
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M=
|
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M=
|
||||||
github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus=
|
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/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 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
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.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||||
github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns=
|
|
||||||
github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o=
|
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
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
|
cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(c.OAuth2.GrantTypes) > 0 {
|
||||||
|
cfg.AllowedGrantTypes = c.OAuth2.GrantTypes
|
||||||
|
}
|
||||||
|
|
||||||
// Apply expiry settings
|
// Apply expiry settings
|
||||||
if c.Expiry.IDTokens != "" {
|
if c.Expiry.IDTokens != "" {
|
||||||
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {
|
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
"github.com/dexidp/dex/server/signer"
|
"github.com/dexidp/dex/server/signer"
|
||||||
"github.com/dexidp/dex/storage"
|
"github.com/dexidp/dex/storage"
|
||||||
"github.com/dexidp/dex/storage/sql"
|
"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/google/uuid"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|||||||
@@ -595,3 +595,90 @@ enablePasswordDB: true
|
|||||||
assert.True(t, cfg.ContinueOnConnectorFailure,
|
assert.True(t, cfg.ContinueOnConnectorFailure,
|
||||||
"buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down")
|
"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
|
// enforces a 128-char limit per value; oversized values are
|
||||||
// truncated rather than failing the request. 0 disables the cap.
|
// truncated rather than failing the request. 0 disables the cap.
|
||||||
MaxValueLength int
|
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.
|
// 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-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},
|
{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",
|
ID: "vertex_ai_api",
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ type identityInjectJSONMetadata struct {
|
|||||||
UserKey string `json:"user_key,omitempty"`
|
UserKey string `json:"user_key,omitempty"`
|
||||||
GroupsKey string `json:"groups_key,omitempty"`
|
GroupsKey string `json:"groups_key,omitempty"`
|
||||||
MaxValueLength int `json:"max_value_length,omitempty"`
|
MaxValueLength int `json:"max_value_length,omitempty"`
|
||||||
|
Sanitize bool `json:"sanitize,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildIdentityInjectConfigJSON walks the enabled providers and emits
|
// 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) {
|
func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) {
|
||||||
rule := identityInjectProvider{ProviderID: p.ID}
|
rule := identityInjectProvider{ProviderID: p.ID}
|
||||||
// Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the
|
// Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the
|
||||||
// shape silently when the catalog entry doesn't declare one — extras
|
// shape silently when the catalog entry doesn't declare one, or when the
|
||||||
// can still apply, see below.
|
// operator disabled metadata for this provider — extras can still apply,
|
||||||
if entry.IdentityInjection != nil {
|
// see below. MetadataDisabled suppresses only the identity dimensions
|
||||||
|
// (user + authorizing group), not the catalog's routing ExtraHeaders.
|
||||||
|
if !p.MetadataDisabled && entry.IdentityInjection != nil {
|
||||||
switch {
|
switch {
|
||||||
case entry.IdentityInjection.HeaderPair != nil:
|
case entry.IdentityInjection.HeaderPair != nil:
|
||||||
rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair)
|
rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair)
|
||||||
@@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject
|
|||||||
UserKey: userKey,
|
UserKey: userKey,
|
||||||
GroupsKey: groupsKey,
|
GroupsKey: groupsKey,
|
||||||
MaxValueLength: jm.MaxValueLength,
|
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")
|
"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
|
// TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI
|
||||||
// Gateway's wiring: HeaderPair shape with fixed wire names dictated
|
// Gateway's wiring: HeaderPair shape with fixed wire names dictated
|
||||||
// by Vercel's Custom Reporting API (ai-reporting-user /
|
// 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
|
// private or self-signed certificate. The synthesiser propagates it into
|
||||||
// the router route so the proxy dials that provider's upstream insecurely.
|
// the router route so the proxy dials that provider's upstream insecurely.
|
||||||
SkipTLSVerification bool `gorm:"column:skip_tls_verification"`
|
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
|
// SessionPrivateKey + SessionPublicKey are the ed25519 keypair the
|
||||||
// synthesised reverse-proxy service uses to sign / verify session
|
// synthesised reverse-proxy service uses to sign / verify session
|
||||||
// JWTs after a successful OIDC handshake. Generated once on
|
// JWTs after a successful OIDC handshake. Generated once on
|
||||||
@@ -137,6 +143,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
|||||||
if req.SkipTlsVerification != nil {
|
if req.SkipTlsVerification != nil {
|
||||||
p.SkipTLSVerification = *req.SkipTlsVerification
|
p.SkipTLSVerification = *req.SkipTlsVerification
|
||||||
}
|
}
|
||||||
|
if req.MetadataDisabled != nil {
|
||||||
|
p.MetadataDisabled = *req.MetadataDisabled
|
||||||
|
}
|
||||||
// Identity-header overrides for catalogs flagged Customizable.
|
// Identity-header overrides for catalogs flagged Customizable.
|
||||||
// nil pointer = "field omitted on the wire" → leave the stored
|
// nil pointer = "field omitted on the wire" → leave the stored
|
||||||
// value untouched (per the openapi description). Empty string is
|
// value untouched (per the openapi description). Empty string is
|
||||||
@@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
|||||||
Models: models,
|
Models: models,
|
||||||
Enabled: p.Enabled,
|
Enabled: p.Enabled,
|
||||||
SkipTlsVerification: p.SkipTLSVerification,
|
SkipTlsVerification: p.SkipTLSVerification,
|
||||||
|
MetadataDisabled: p.MetadataDisabled,
|
||||||
CreatedAt: &created,
|
CreatedAt: &created,
|
||||||
UpdatedAt: &updated,
|
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.SkipTLSVerification, "explicit false must clear skip_tls_verification")
|
||||||
assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
+9
-4
@@ -1,19 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
// nolint:gosec
|
// nolint:gosec
|
||||||
_ "net/http/pprof"
|
_ "net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/cmd"
|
"github.com/netbirdio/netbird/management/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
go func() {
|
if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" {
|
||||||
log.Println(http.ListenAndServe("localhost:6060", nil))
|
log.Infof("pprof enabled, listening on: %s", pprofAddr)
|
||||||
}()
|
go func() {
|
||||||
|
log.Println(http.ListenAndServe(pprofAddr, nil))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sanity: an actually different range still triggers reallocation.
|
// 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{
|
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
|
||||||
PeerLoginExpirationEnabled: true,
|
PeerLoginExpirationEnabled: true,
|
||||||
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
|
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ type EmbeddedIdPConfig struct {
|
|||||||
DashboardPostLogoutRedirectURIs []string
|
DashboardPostLogoutRedirectURIs []string
|
||||||
// StaticConnectors are additional connectors to seed during initialization
|
// StaticConnectors are additional connectors to seed during initialization
|
||||||
StaticConnectors []dex.Connector
|
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.
|
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
||||||
@@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
|||||||
},
|
},
|
||||||
OAuth2: dex.OAuth2{
|
OAuth2: dex.OAuth2{
|
||||||
SkipApprovalScreen: true,
|
SkipApprovalScreen: true,
|
||||||
|
GrantTypes: c.GrantTypes,
|
||||||
},
|
},
|
||||||
Frontend: dex.Frontend{
|
Frontend: dex.Frontend{
|
||||||
Issuer: "NetBird",
|
Issuer: "NetBird",
|
||||||
|
|||||||
@@ -1606,7 +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_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
|
||||||
settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled,
|
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_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only,
|
||||||
settings_dashboard_features,
|
settings_dashboard_features, settings_auto_update_version, settings_auto_update_always,
|
||||||
|
settings_peer_expose_enabled, settings_peer_expose_groups,
|
||||||
-- Embedded ExtraSettings
|
-- Embedded ExtraSettings
|
||||||
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
|
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
|
||||||
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
|
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
|
||||||
@@ -1632,6 +1633,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
|
|||||||
sMetricsPushEnabled sql.NullBool
|
sMetricsPushEnabled sql.NullBool
|
||||||
sAgentNetworkOnly sql.NullBool
|
sAgentNetworkOnly sql.NullBool
|
||||||
sDashboardFeatures sql.NullString
|
sDashboardFeatures sql.NullString
|
||||||
|
autoUpdateVersion sql.NullString
|
||||||
|
autoUpdateAlways sql.NullBool
|
||||||
|
peerExposeEnabled sql.NullBool
|
||||||
|
peerExposeGroups sql.NullString
|
||||||
sExtraPeerApprovalEnabled sql.NullBool
|
sExtraPeerApprovalEnabled sql.NullBool
|
||||||
sExtraUserApprovalRequired sql.NullBool
|
sExtraUserApprovalRequired sql.NullBool
|
||||||
sExtraIntegratedValidator sql.NullString
|
sExtraIntegratedValidator sql.NullString
|
||||||
@@ -1655,7 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
|
|||||||
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
|
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
|
||||||
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
|
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
|
||||||
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
|
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
|
||||||
&sDashboardFeatures,
|
&sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways,
|
||||||
|
&peerExposeEnabled, &peerExposeGroups,
|
||||||
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
|
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
|
||||||
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
|
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
|
||||||
)
|
)
|
||||||
@@ -1747,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
|
|||||||
if sIPv6EnabledGroups.Valid {
|
if sIPv6EnabledGroups.Valid {
|
||||||
_ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups)
|
_ = 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 {
|
if sExtraPeerApprovalEnabled.Valid {
|
||||||
account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool
|
account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -34,6 +35,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/management/server/util"
|
"github.com/netbirdio/netbird/management/server/util"
|
||||||
nbroute "github.com/netbirdio/netbird/route"
|
nbroute "github.com/netbirdio/netbird/route"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
|
"github.com/netbirdio/netbird/shared/testing_helpers"
|
||||||
"github.com/netbirdio/netbird/util/crypt"
|
"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) {
|
func TestSqlite_DeleteAccount(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("The SQLite store is not properly supported by Windows yet")
|
t.Skip("The SQLite store is not properly supported by Windows yet")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/ssh/auth"
|
"github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
@@ -42,6 +43,14 @@ type NetworkMapComponents struct {
|
|||||||
PostureFailedPeers map[string]map[string]struct{}
|
PostureFailedPeers map[string]map[string]struct{}
|
||||||
|
|
||||||
RouterPeers map[string]*nbpeer.Peer
|
RouterPeers map[string]*nbpeer.Peer
|
||||||
|
|
||||||
|
routesByPeerOnce sync.Once
|
||||||
|
routesByPeerIdx map[string][]routeIndexEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeIndexEntry struct {
|
||||||
|
route *route.Route
|
||||||
|
viaGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountSettingsInfo struct {
|
type AccountSettingsInfo struct {
|
||||||
@@ -530,33 +539,43 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
|
|||||||
disabledRoutes = append(disabledRoutes, r)
|
disabledRoutes = append(disabledRoutes, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range c.Routes {
|
for _, entry := range c.routesByPeer()[peerID] {
|
||||||
for _, groupID := range r.PeerGroups {
|
if entry.viaGroup {
|
||||||
group := c.GetGroupInfo(groupID)
|
newPeerRoute := entry.route.Copy()
|
||||||
if group == nil {
|
newPeerRoute.PeerGroups = nil
|
||||||
continue
|
newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
|
||||||
}
|
takeRoute(newPeerRoute)
|
||||||
for _, id := range group.Peers {
|
continue
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
takeRoute(entry.route.Copy())
|
||||||
}
|
}
|
||||||
|
|
||||||
return enabledRoutes, disabledRoutes
|
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 {
|
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
|
||||||
var filteredRoutes []*route.Route
|
var filteredRoutes []*route.Route
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ COPY proxy ./proxy
|
|||||||
COPY route ./route
|
COPY route ./route
|
||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY sharedsock ./sharedsock
|
COPY sharedsock ./sharedsock
|
||||||
|
COPY trustedproxy ./trustedproxy
|
||||||
COPY upload-server ./upload-server
|
COPY upload-server ./upload-server
|
||||||
COPY util ./util
|
COPY util ./util
|
||||||
COPY version ./version
|
COPY version ./version
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/embed"
|
"github.com/netbirdio/netbird/client/embed"
|
||||||
"github.com/netbirdio/netbird/proxy"
|
"github.com/netbirdio/netbird/proxy"
|
||||||
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
|
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util"
|
"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)
|
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies)
|
parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -66,7 +67,7 @@ type denyBucket struct {
|
|||||||
type Logger struct {
|
type Logger struct {
|
||||||
client gRPCClient
|
client gRPCClient
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
trustedProxies []netip.Prefix
|
trustedProxies *trustedproxy.List
|
||||||
|
|
||||||
usageMux sync.Mutex
|
usageMux sync.Mutex
|
||||||
domainUsage map[string]*domainUsage
|
domainUsage map[string]*domainUsage
|
||||||
@@ -82,7 +83,7 @@ type Logger struct {
|
|||||||
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
||||||
// configures which upstream proxy IP ranges are trusted for extracting
|
// configures which upstream proxy IP ranges are trusted for extracting
|
||||||
// the real client IP from X-Forwarded-For headers.
|
// 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 {
|
if logger == nil {
|
||||||
logger = log.StandardLogger()
|
logger = log.StandardLogger()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"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
|
// extractSourceIP resolves the real client IP from the request using trusted
|
||||||
// proxy configuration. When trustedProxies is non-empty and the direct
|
// proxy configuration. When trustedProxies is non-empty and the direct
|
||||||
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
||||||
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
||||||
func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr {
|
func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr {
|
||||||
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,11 @@ type JSONMetadataRule struct {
|
|||||||
UserKey string `json:"user_key,omitempty"`
|
UserKey string `json:"user_key,omitempty"`
|
||||||
GroupsKey string `json:"groups_key,omitempty"`
|
GroupsKey string `json:"groups_key,omitempty"`
|
||||||
MaxValueLength int `json:"max_value_length,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
|
// 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 := &middleware.Mutations{}
|
||||||
mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header)
|
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{}
|
payload := map[string]string{}
|
||||||
if rule.UserKey != "" {
|
if rule.UserKey != "" {
|
||||||
if identity := identityFor(in); identity != "" {
|
if identity := identityFor(in); identity != "" {
|
||||||
payload[rule.UserKey] = truncate(identity, rule.MaxValueLength)
|
payload[rule.UserKey] = emit(identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rule.GroupsKey != "" {
|
if rule.GroupsKey != "" {
|
||||||
if csv := authorisingTagsCSV(in); csv != "" {
|
if csv := authorisingTagsCSV(in); csv != "" {
|
||||||
payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength)
|
payload[rule.GroupsKey] = emit(csv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
@@ -359,6 +365,36 @@ func truncate(s string, maxBytes int) string {
|
|||||||
return s[:maxBytes]
|
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
|
// tagsIDsFromAuthorising reads llm_router's authorising-groups metadata
|
||||||
// (a CSV of group ids) and returns the parsed slice. Returns nil when
|
// (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
|
// 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")
|
"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
|
// TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the
|
||||||
// anti-spoof Remove still fires when there's nothing to stamp.
|
// anti-spoof Remove still fires when there's nothing to stamp.
|
||||||
func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) {
|
func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) {
|
||||||
|
|||||||
@@ -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"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
|||||||
if candidate == model {
|
if candidate == model {
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReverseProxy struct {
|
type ReverseProxy struct {
|
||||||
@@ -29,10 +30,10 @@ type ReverseProxy struct {
|
|||||||
// forwardedProto overrides the X-Forwarded-Proto header value.
|
// forwardedProto overrides the X-Forwarded-Proto header value.
|
||||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||||
forwardedProto string
|
forwardedProto string
|
||||||
// trustedProxies is a list of IP prefixes for trusted upstream proxies.
|
// trustedProxies is the set of trusted upstream proxies. When the direct
|
||||||
// When the direct connection comes from a trusted proxy, forwarding
|
// connection comes from a trusted proxy, forwarding headers are preserved
|
||||||
// headers are preserved and appended to instead of being stripped.
|
// and appended to instead of being stripped.
|
||||||
trustedProxies []netip.Prefix
|
trustedProxies *trustedproxy.List
|
||||||
mappingsMux sync.RWMutex
|
mappingsMux sync.RWMutex
|
||||||
mappings map[string]Mapping
|
mappings map[string]Mapping
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option {
|
|||||||
// between requested URLs and targets.
|
// between requested URLs and targets.
|
||||||
// The internal mappings can be modified using the AddMapping
|
// The internal mappings can be modified using the AddMapping
|
||||||
// and RemoveMapping functions.
|
// 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 {
|
if logger == nil {
|
||||||
logger = log.StandardLogger()
|
logger = log.StandardLogger()
|
||||||
}
|
}
|
||||||
@@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
|
|||||||
if !types.IsOverlayOrigin(r.Context()) {
|
if !types.IsOverlayOrigin(r.Context()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
srcIP := extractHostIP(r.RemoteAddr)
|
srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr)
|
||||||
if !srcIP.IsValid() {
|
if !srcIP.IsValid() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
|
|||||||
|
|
||||||
stampNetBirdIdentity(r)
|
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)
|
p.setTrustedForwardingHeaders(r, clientIP)
|
||||||
} else {
|
} else {
|
||||||
p.setUntrustedForwardingHeaders(r, clientIP)
|
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 != "" {
|
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
r.Out.Header.Set("X-Real-IP", realIP)
|
r.Out.Header.Set("X-Real-IP", realIP)
|
||||||
} else {
|
} 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())
|
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/roundtrip"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
||||||
@@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
||||||
target, _ := url.Parse("http://backend.internal:8080")
|
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) {
|
t.Run("appends to X-Forwarded-For", func(t *testing.T) {
|
||||||
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-4
@@ -2,13 +2,13 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/netip"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/embed"
|
"github.com/netbirdio/netbird/client/embed"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/acme"
|
"github.com/netbirdio/netbird/proxy/internal/acme"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config bundles every knob the proxy reads at construction time. It mirrors
|
// 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
|
// ForwardedProto overrides the X-Forwarded-Proto value sent to
|
||||||
// backends. Valid values: "auto", "http", "https".
|
// backends. Valid values: "auto", "http", "https".
|
||||||
ForwardedProto string
|
ForwardedProto string
|
||||||
// TrustedProxies is a list of IP prefixes for trusted upstream
|
// TrustedProxies is the set of trusted upstream proxies that may set
|
||||||
// proxies that may set forwarding headers.
|
// forwarding headers.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies *trustedproxy.List
|
||||||
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
||||||
// Zero asks the OS for a random port.
|
// Zero asks the OS for a random port.
|
||||||
WireguardPort uint16
|
WireguardPort uint16
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
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,
|
ProxyProtocol: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
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{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
@@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
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{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
@@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
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{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
|
|||||||
+30
-31
@@ -67,6 +67,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util/embeddedroots"
|
"github.com/netbirdio/netbird/util/embeddedroots"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,19 +80,19 @@ type portRouter struct {
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
mgmtClient proto.ProxyServiceClient
|
mgmtClient proto.ProxyServiceClient
|
||||||
proxy *proxy.ReverseProxy
|
proxy *proxy.ReverseProxy
|
||||||
netbird *roundtrip.NetBird
|
netbird *roundtrip.NetBird
|
||||||
acme *acme.Manager
|
acme *acme.Manager
|
||||||
staticCertWatcher *certwatch.Watcher
|
staticCertWatcher *certwatch.Watcher
|
||||||
auth *auth.Middleware
|
auth *auth.Middleware
|
||||||
http *http.Server
|
http *http.Server
|
||||||
https *http.Server
|
https *http.Server
|
||||||
debug *http.Server
|
debug *http.Server
|
||||||
healthServer *health.Server
|
healthServer *health.Server
|
||||||
healthChecker *health.Checker
|
healthChecker *health.Checker
|
||||||
meter *proxymetrics.Metrics
|
meter *proxymetrics.Metrics
|
||||||
accessLog *accesslog.Logger
|
accessLog *accesslog.Logger
|
||||||
// middlewareManager drives per-target middleware dispatch. Always
|
// middlewareManager drives per-target middleware dispatch. Always
|
||||||
// constructed during boot; an empty registry produces empty chains and
|
// constructed during boot; an empty registry produces empty chains and
|
||||||
// the reverse-proxy stays on the no-capture fast path.
|
// 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.
|
// middlewareRegistry is the source of registered middleware factories.
|
||||||
// Concrete middlewares register themselves through init().
|
// Concrete middlewares register themselves through init().
|
||||||
middlewareRegistry *middleware.Registry
|
middlewareRegistry *middleware.Registry
|
||||||
mainRouter *nbtcp.Router
|
mainRouter *nbtcp.Router
|
||||||
mainPort uint16
|
mainPort uint16
|
||||||
udpMu sync.Mutex
|
udpMu sync.Mutex
|
||||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||||
udpRelayWg sync.WaitGroup
|
udpRelayWg sync.WaitGroup
|
||||||
portMu sync.RWMutex
|
portMu sync.RWMutex
|
||||||
portRouters map[uint16]*portRouter
|
portRouters map[uint16]*portRouter
|
||||||
svcPorts map[types.ServiceID][]uint16
|
svcPorts map[types.ServiceID][]uint16
|
||||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||||
portRouterWg sync.WaitGroup
|
portRouterWg sync.WaitGroup
|
||||||
|
|
||||||
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
||||||
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
// 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.
|
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
|
||||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||||
ForwardedProto string
|
ForwardedProto string
|
||||||
// TrustedProxies is a list of IP prefixes for trusted upstream proxies.
|
// TrustedProxies is the set of trusted upstream proxies. When set,
|
||||||
// When set, forwarding headers from these sources are preserved and
|
// forwarding headers from these sources are preserved and appended to
|
||||||
// appended to instead of being stripped.
|
// instead of being stripped.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies *trustedproxy.List
|
||||||
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
||||||
// for a random OS-assigned port. A fixed port only works with
|
// for a random OS-assigned port. A fixed port only works with
|
||||||
// single-account deployments; multiple accounts will fail to bind
|
// single-account deployments; multiple accounts will fail to bind
|
||||||
@@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener {
|
|||||||
Listener: ln,
|
Listener: ln,
|
||||||
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
||||||
}
|
}
|
||||||
if len(s.TrustedProxies) > 0 {
|
if !s.TrustedProxies.Empty() {
|
||||||
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
||||||
} else {
|
} else {
|
||||||
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
|
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()
|
addr = addr.Unmap()
|
||||||
|
|
||||||
// called per accept
|
// called per accept
|
||||||
for _, prefix := range s.TrustedProxies {
|
if s.TrustedProxies.Contains(addr) {
|
||||||
if prefix.Contains(addr) {
|
return proxyproto.REQUIRE, nil
|
||||||
return proxyproto.REQUIRE, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return proxyproto.IGNORE, 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-2
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/metrics"
|
"github.com/netbirdio/netbird/shared/metrics"
|
||||||
"github.com/netbirdio/netbird/shared/relay/auth"
|
"github.com/netbirdio/netbird/shared/relay/auth"
|
||||||
"github.com/netbirdio/netbird/stun"
|
"github.com/netbirdio/netbird/stun"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,9 @@ type Config struct {
|
|||||||
LogLevel string
|
LogLevel string
|
||||||
LogFile string
|
LogFile string
|
||||||
HealthcheckListenAddress 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
|
// STUN server configuration
|
||||||
EnableSTUN bool
|
EnableSTUN bool
|
||||||
STUNPorts []int
|
STUNPorts []int
|
||||||
@@ -116,6 +120,7 @@ func init() {
|
|||||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
||||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
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().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().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().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)")
|
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)
|
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{
|
srvListenerCfg := server.ListenerConfig{
|
||||||
Address: cobraConfig.ListenAddress,
|
Address: cobraConfig.ListenAddress,
|
||||||
|
TrustedProxies: trustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
||||||
@@ -173,7 +185,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret))
|
hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret))
|
||||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
|
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
|
||||||
|
|
||||||
cfg := server.Config{
|
cfg := server.Config{
|
||||||
Meter: metricsServer.Meter,
|
Meter: metricsServer.Meter,
|
||||||
|
|||||||
@@ -5,14 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/relay/server/listener"
|
"github.com/netbirdio/netbird/relay/server/listener"
|
||||||
"github.com/netbirdio/netbird/shared/relay/messages"
|
"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 (
|
const (
|
||||||
@@ -23,55 +17,30 @@ const (
|
|||||||
|
|
||||||
type Validator interface {
|
type Validator interface {
|
||||||
Validate(any) error
|
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 {
|
type preparedMsg struct {
|
||||||
responseHelloMsg []byte
|
responseAuthMsg []byte
|
||||||
responseAuthMsg []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPreparedMsg(instanceURL string) (*preparedMsg, error) {
|
func newPreparedMsg(instanceURL string) (*preparedMsg, error) {
|
||||||
rhm, err := marshalResponseHelloMsg(instanceURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ram, err := messages.MarshalAuthResponse(instanceURL)
|
ram, err := messages.MarshalAuthResponse(instanceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal auth response msg: %w", err)
|
return nil, fmt.Errorf("failed to marshal auth response msg: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &preparedMsg{
|
return &preparedMsg{
|
||||||
responseHelloMsg: rhm,
|
responseAuthMsg: ram,
|
||||||
responseAuthMsg: ram,
|
|
||||||
}, nil
|
}, 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 {
|
type handshake struct {
|
||||||
conn listener.Conn
|
conn listener.Conn
|
||||||
validator Validator
|
validator Validator
|
||||||
preparedMsg *preparedMsg
|
preparedMsg *preparedMsg
|
||||||
|
|
||||||
handshakeMethodAuth bool
|
peerID *messages.PeerID
|
||||||
peerID *messages.PeerID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) {
|
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)
|
return nil, fmt.Errorf("determine message type from %s: %w", h.conn.RemoteAddr(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerID *messages.PeerID
|
if msgType != messages.MsgTypeAuth {
|
||||||
switch msgType {
|
|
||||||
//nolint:staticcheck
|
|
||||||
case messages.MsgTypeHello:
|
|
||||||
peerID, err = h.handleHelloMsg(buf)
|
|
||||||
case messages.MsgTypeAuth:
|
|
||||||
h.handshakeMethodAuth = true
|
|
||||||
peerID, err = h.handleAuthMsg(buf)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr())
|
return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peerID, err := h.handleAuthMsg(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return peerID, err
|
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 {
|
func (h *handshake) handshakeResponse(ctx context.Context) error {
|
||||||
var responseMsg []byte
|
if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil {
|
||||||
if h.handshakeMethodAuth {
|
|
||||||
responseMsg = h.preparedMsg.responseAuthMsg
|
|
||||||
} else {
|
|
||||||
responseMsg = h.preparedMsg.responseHelloMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := h.conn.Write(ctx, responseMsg); err != nil {
|
|
||||||
return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err)
|
return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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) {
|
func (h *handshake) handleAuthMsg(buf []byte) (*messages.PeerID, error) {
|
||||||
rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf)
|
rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf)
|
||||||
if err != nil {
|
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 {
|
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())
|
log.Infof("QUIC client connected from: %s", session.RemoteAddr())
|
||||||
conn := NewConn(session)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/relay/protocol"
|
"github.com/netbirdio/netbird/relay/protocol"
|
||||||
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
|
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
|
||||||
"github.com/netbirdio/netbird/shared/relay"
|
"github.com/netbirdio/netbird/shared/relay"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,6 +28,9 @@ type Listener struct {
|
|||||||
Address string
|
Address string
|
||||||
// TLSConfig is the TLS configuration for the server.
|
// TLSConfig is the TLS configuration for the server.
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
// TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port
|
||||||
|
// headers are trusted. Headers from any other immediate peer are ignored.
|
||||||
|
TrustedProxies *trustedproxy.List
|
||||||
|
|
||||||
server *http.Server
|
server *http.Server
|
||||||
acceptFn func(conn relaylistener.Conn)
|
acceptFn func(conn relaylistener.Conn)
|
||||||
@@ -75,7 +79,7 @@ func (l *Listener) Shutdown(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
||||||
connRemoteAddr := remoteAddr(r)
|
connRemoteAddr := remoteAddr(r, l.TrustedProxies)
|
||||||
|
|
||||||
acceptOptions := &websocket.AcceptOptions{
|
acceptOptions := &websocket.AcceptOptions{
|
||||||
OriginPatterns: []string{"*"},
|
OriginPatterns: []string{"*"},
|
||||||
@@ -102,9 +106,17 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
|||||||
l.acceptFn(conn)
|
l.acceptFn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteAddr(r *http.Request) string {
|
func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string {
|
||||||
if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" {
|
realIP := r.Header.Get("X-Real-Ip")
|
||||||
|
realPort := r.Header.Get("X-Real-Port")
|
||||||
|
if realIP == "" || realPort == "" {
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
return net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port"))
|
|
||||||
|
if !trustedProxies.IsTrusted(r.RemoteAddr) {
|
||||||
|
log.Debugf("ignoring X-Real-Ip header from untrusted peer %s", r.RemoteAddr)
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return net.JoinHostPort(realIP, realPort)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import (
|
|||||||
"github.com/netbirdio/netbird/relay/server/listener/quic"
|
"github.com/netbirdio/netbird/relay/server/listener/quic"
|
||||||
"github.com/netbirdio/netbird/relay/server/listener/ws"
|
"github.com/netbirdio/netbird/relay/server/listener/ws"
|
||||||
quictls "github.com/netbirdio/netbird/shared/relay/tls"
|
quictls "github.com/netbirdio/netbird/shared/relay/tls"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListenerConfig is the configuration for the listener.
|
// ListenerConfig is the configuration for the listener.
|
||||||
// Address: the address to bind the listener to. It could be an address behind a reverse proxy.
|
// Address: the address to bind the listener to. It could be an address behind a reverse proxy.
|
||||||
// TLSConfig: the TLS configuration for the listener.
|
// TLSConfig: the TLS configuration for the listener.
|
||||||
|
// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted.
|
||||||
type ListenerConfig struct {
|
type ListenerConfig struct {
|
||||||
Address string
|
Address string
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
TrustedProxies *trustedproxy.List
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main entry point for the relay server.
|
// Server is the main entry point for the relay server.
|
||||||
@@ -62,8 +65,9 @@ func NewServer(config Config) (*Server, error) {
|
|||||||
// Listen starts the relay server.
|
// Listen starts the relay server.
|
||||||
func (r *Server) Listen(cfg ListenerConfig) error {
|
func (r *Server) Listen(cfg ListenerConfig) error {
|
||||||
wSListener := &ws.Listener{
|
wSListener := &ws.Listener{
|
||||||
Address: cfg.Address,
|
Address: cfg.Address,
|
||||||
TLSConfig: cfg.TLSConfig,
|
TLSConfig: cfg.TLSConfig,
|
||||||
|
TrustedProxies: cfg.TrustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.listenerMux.Lock()
|
r.listenerMux.Lock()
|
||||||
|
|||||||
@@ -5164,6 +5164,10 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate.
|
description: Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate.
|
||||||
example: false
|
example: false
|
||||||
|
metadata_disabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
|
||||||
|
example: false
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -5184,6 +5188,7 @@ components:
|
|||||||
- models
|
- models
|
||||||
- enabled
|
- enabled
|
||||||
- skip_tls_verification
|
- skip_tls_verification
|
||||||
|
- metadata_disabled
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
AgentNetworkProviderRequest:
|
AgentNetworkProviderRequest:
|
||||||
@@ -5240,6 +5245,10 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
|
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
|
||||||
example: false
|
example: false
|
||||||
|
metadata_disabled:
|
||||||
|
type: boolean
|
||||||
|
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
|
||||||
|
example: false
|
||||||
required:
|
required:
|
||||||
- provider_id
|
- provider_id
|
||||||
- name
|
- name
|
||||||
|
|||||||
@@ -2227,6 +2227,9 @@ type AgentNetworkProvider struct {
|
|||||||
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
||||||
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
||||||
|
|
||||||
|
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
|
||||||
|
MetadataDisabled bool `json:"metadata_disabled"`
|
||||||
|
|
||||||
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
||||||
Models []AgentNetworkProviderModel `json:"models"`
|
Models []AgentNetworkProviderModel `json:"models"`
|
||||||
|
|
||||||
@@ -2278,6 +2281,9 @@ type AgentNetworkProviderRequest struct {
|
|||||||
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
|
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
|
||||||
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
||||||
|
|
||||||
|
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
|
||||||
|
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
|
||||||
|
|
||||||
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
||||||
Models *[]AgentNetworkProviderModel `json:"models,omitempty"`
|
Models *[]AgentNetworkProviderModel `json:"models,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,3 @@ type Auth struct {
|
|||||||
func (a *Auth) Validate(any) error {
|
func (a *Auth) Validate(any) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) ValidateHelloMsgType(any) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package hmac
|
package hmac
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,14 +16,6 @@ type Token struct {
|
|||||||
Signature string
|
Signature string
|
||||||
}
|
}
|
||||||
|
|
||||||
func unmarshalToken(payload []byte) (Token, error) {
|
|
||||||
var creds Token
|
|
||||||
buffer := bytes.NewBuffer(payload)
|
|
||||||
decoder := gob.NewDecoder(buffer)
|
|
||||||
err := decoder.Decode(&creds)
|
|
||||||
return creds, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TimedHMAC generates a token with TTL and uses a pre-shared secret known to the relay server
|
// TimedHMAC generates a token with TTL and uses a pre-shared secret known to the relay server
|
||||||
type TimedHMAC struct {
|
type TimedHMAC struct {
|
||||||
secret string
|
secret string
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package hmac
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimedHMACValidator struct {
|
|
||||||
*TimedHMAC
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimedHMACValidator(secret string, duration time.Duration) *TimedHMACValidator {
|
|
||||||
ta := NewTimedHMAC(secret, duration)
|
|
||||||
return &TimedHMACValidator{
|
|
||||||
ta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *TimedHMACValidator) Validate(credentials any) error {
|
|
||||||
b, ok := credentials.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid credentials type")
|
|
||||||
}
|
|
||||||
c, err := unmarshalToken(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf("failed to unmarshal token: %s", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return a.TimedHMAC.Validate(sha256.New, c)
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
|
||||||
authv2 "github.com/netbirdio/netbird/shared/relay/auth/hmac/v2"
|
authv2 "github.com/netbirdio/netbird/shared/relay/auth/hmac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TimedHMACValidator struct {
|
type TimedHMACValidator struct {
|
||||||
authenticatorV2 *authv2.Validator
|
authenticatorV2 *authv2.Validator
|
||||||
authenticator *auth.TimedHMACValidator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimedHMACValidator(secret []byte, duration time.Duration) *TimedHMACValidator {
|
func NewTimedHMACValidator(secret []byte) *TimedHMACValidator {
|
||||||
return &TimedHMACValidator{
|
return &TimedHMACValidator{
|
||||||
authenticatorV2: authv2.NewValidator(secret),
|
authenticatorV2: authv2.NewValidator(secret),
|
||||||
authenticator: auth.NewTimedHMACValidator(string(secret), duration),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *TimedHMACValidator) Validate(credentials any) error {
|
func (a *TimedHMACValidator) Validate(credentials any) error {
|
||||||
return a.authenticatorV2.Validate(credentials)
|
return a.authenticatorV2.Validate(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *TimedHMACValidator) ValidateHelloMsgType(credentials any) error {
|
|
||||||
return a.authenticator.Validate(credentials)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
// Deprecated: This package is deprecated and will be removed in a future release.
|
|
||||||
package address
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Address struct {
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (addr *Address) Marshal() ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := gob.NewEncoder(&buf)
|
|
||||||
if err := enc.Encode(addr); err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Address: %w", err)
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Deprecated: This package is deprecated and will be removed in a future release.
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Algorithm int
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlgoUnknown Algorithm = iota
|
|
||||||
AlgoHMACSHA256
|
|
||||||
AlgoHMACSHA512
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a Algorithm) String() string {
|
|
||||||
switch a {
|
|
||||||
case AlgoHMACSHA256:
|
|
||||||
return "HMAC-SHA256"
|
|
||||||
case AlgoHMACSHA512:
|
|
||||||
return "HMAC-SHA512"
|
|
||||||
default:
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Msg struct {
|
|
||||||
AuthAlgorithm Algorithm
|
|
||||||
AdditionalData []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func UnmarshalMsg(data []byte) (*Msg, error) {
|
|
||||||
var msg *Msg
|
|
||||||
|
|
||||||
buf := bytes.NewBuffer(data)
|
|
||||||
dec := gob.NewDecoder(buf)
|
|
||||||
if err := dec.Decode(&msg); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode Msg: %w", err)
|
|
||||||
}
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,10 @@ const (
|
|||||||
CurrentProtocolVersion = 1
|
CurrentProtocolVersion = 1
|
||||||
|
|
||||||
MsgTypeUnknown MsgType = 0
|
MsgTypeUnknown MsgType = 0
|
||||||
// Deprecated: Use MsgTypeAuth instead.
|
// MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake
|
||||||
MsgTypeHello = 1
|
// message types. They are retained only to reserve wire values 1 and 2 so
|
||||||
// Deprecated: Use MsgTypeAuthResponse instead.
|
// the values are never reused; the server rejects both.
|
||||||
|
MsgTypeHello = 1
|
||||||
MsgTypeHelloResponse = 2
|
MsgTypeHelloResponse = 2
|
||||||
MsgTypeTransport = 3
|
MsgTypeTransport = 3
|
||||||
MsgTypeClose = 4
|
MsgTypeClose = 4
|
||||||
@@ -42,10 +43,6 @@ const (
|
|||||||
offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte
|
offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte
|
||||||
headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth
|
headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth
|
||||||
|
|
||||||
// hello message
|
|
||||||
headerSizeHello = sizeOfMagicByte + peerIDSize
|
|
||||||
headerSizeHelloResp = 0
|
|
||||||
|
|
||||||
// transport
|
// transport
|
||||||
headerSizeTransport = peerIDSize
|
headerSizeTransport = peerIDSize
|
||||||
offsetTransportID = sizeOfProtoHeader
|
offsetTransportID = sizeOfProtoHeader
|
||||||
@@ -113,7 +110,6 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) {
|
|||||||
msgType := MsgType(msg[1])
|
msgType := MsgType(msg[1])
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case
|
case
|
||||||
MsgTypeHello,
|
|
||||||
MsgTypeAuth,
|
MsgTypeAuth,
|
||||||
MsgTypeTransport,
|
MsgTypeTransport,
|
||||||
MsgTypeClose,
|
MsgTypeClose,
|
||||||
@@ -135,7 +131,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) {
|
|||||||
msgType := MsgType(msg[1])
|
msgType := MsgType(msg[1])
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case
|
case
|
||||||
MsgTypeHelloResponse,
|
|
||||||
MsgTypeAuthResponse,
|
MsgTypeAuthResponse,
|
||||||
MsgTypeTransport,
|
MsgTypeTransport,
|
||||||
MsgTypeClose,
|
MsgTypeClose,
|
||||||
@@ -148,67 +143,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use MarshalAuthMsg instead.
|
|
||||||
// MarshalHelloMsg initial hello message
|
|
||||||
// The Hello message is the first message sent by a client after establishing a connection with the Relay server. This
|
|
||||||
// message is used to authenticate the client with the server. The authentication is done using an HMAC method.
|
|
||||||
// The protocol does not limit to use HMAC, it can be any other method. If the authentication failed the server will
|
|
||||||
// close the network connection without any response.
|
|
||||||
func MarshalHelloMsg(peerID PeerID, additions []byte) ([]byte, error) {
|
|
||||||
msg := make([]byte, sizeOfProtoHeader+sizeOfMagicByte, sizeOfProtoHeader+headerSizeHello+len(additions))
|
|
||||||
|
|
||||||
msg[0] = byte(CurrentProtocolVersion)
|
|
||||||
msg[1] = byte(MsgTypeHello)
|
|
||||||
|
|
||||||
copy(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader)
|
|
||||||
|
|
||||||
msg = append(msg, peerID[:]...)
|
|
||||||
msg = append(msg, additions...)
|
|
||||||
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use UnmarshalAuthMsg instead.
|
|
||||||
// UnmarshalHelloMsg extracts peerID and the additional data from the hello message. The Additional data is used to
|
|
||||||
// authenticate the client with the server.
|
|
||||||
func UnmarshalHelloMsg(msg []byte) (*PeerID, []byte, error) {
|
|
||||||
if len(msg) < sizeOfProtoHeader+headerSizeHello {
|
|
||||||
return nil, nil, ErrInvalidMessageLength
|
|
||||||
}
|
|
||||||
if !bytes.Equal(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader) {
|
|
||||||
return nil, nil, errors.New("invalid magic header")
|
|
||||||
}
|
|
||||||
|
|
||||||
peerID := PeerID(msg[sizeOfProtoHeader+sizeOfMagicByte : sizeOfProtoHeader+headerSizeHello])
|
|
||||||
|
|
||||||
return &peerID, msg[headerSizeHello:], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use MarshalAuthResponse instead.
|
|
||||||
// MarshalHelloResponse creates a response message to the hello message.
|
|
||||||
// In case of success connection the server response with a Hello Response message. This message contains the server's
|
|
||||||
// instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay
|
|
||||||
// servers.
|
|
||||||
func MarshalHelloResponse(additionalData []byte) ([]byte, error) {
|
|
||||||
msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+headerSizeHelloResp+len(additionalData))
|
|
||||||
|
|
||||||
msg[0] = byte(CurrentProtocolVersion)
|
|
||||||
msg[1] = byte(MsgTypeHelloResponse)
|
|
||||||
|
|
||||||
msg = append(msg, additionalData...)
|
|
||||||
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use UnmarshalAuthResponse instead.
|
|
||||||
// UnmarshalHelloResponse extracts the additional data from the hello response message.
|
|
||||||
func UnmarshalHelloResponse(msg []byte) ([]byte, error) {
|
|
||||||
if len(msg) < sizeOfProtoHeader+headerSizeHelloResp {
|
|
||||||
return nil, ErrInvalidMessageLength
|
|
||||||
}
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalAuthMsg initial authentication message
|
// MarshalAuthMsg initial authentication message
|
||||||
// The Auth message is the first message sent by a client after establishing a connection with the Relay server. This
|
// The Auth message is the first message sent by a client after establishing a connection with the Relay server. This
|
||||||
// message is used to authenticate the client with the server. The authentication is done using an HMAC method.
|
// message is used to authenticate the client with the server. The authentication is done using an HMAC method.
|
||||||
|
|||||||
@@ -4,28 +4,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMarshalHelloMsg(t *testing.T) {
|
func TestDetermineClientMessageTypeRejectsHello(t *testing.T) {
|
||||||
peerID := HashID("abdFAaBcawquEiCMzAabYosuUaGLtSNhKxz+")
|
// The reserved legacy Hello message (type 1) must be rejected by the server.
|
||||||
msg, err := MarshalHelloMsg(peerID, nil)
|
msg := []byte{byte(CurrentProtocolVersion), byte(MsgTypeHello)}
|
||||||
if err != nil {
|
if _, err := DetermineClientMessageType(msg); err == nil {
|
||||||
t.Fatalf("error: %v", err)
|
t.Fatalf("expected hello message type to be rejected")
|
||||||
}
|
|
||||||
|
|
||||||
msgType, err := DetermineClientMessageType(msg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgType != MsgTypeHello {
|
|
||||||
t.Errorf("expected %d, got %d", MsgTypeHello, msgType)
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedPeerID, _, err := UnmarshalHelloMsg(msg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %v", err)
|
|
||||||
}
|
|
||||||
if receivedPeerID.String() != peerID.String() {
|
|
||||||
t.Errorf("expected %s, got %s", peerID, receivedPeerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package testing_helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PopulateFields struct {
|
||||||
|
CustomFieldSetters map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error)
|
||||||
|
TagsToSkip map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPopulateFields() *PopulateFields {
|
||||||
|
return &PopulateFields{CustomFieldSetters: defaultCustomFieldSetters(), TagsToSkip: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PopulateFields) WithCustomFieldSetter(t reflect.Type, f func(this *PopulateFields, field reflect.Value) (int, error)) *PopulateFields {
|
||||||
|
p.CustomFieldSetters[t] = f
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PopulateFields) WithSkippedTag(tag, value string) *PopulateFields {
|
||||||
|
p.TagsToSkip[tag] = value
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PopulateFields) PopulateAll(v reflect.Value) (int, error) {
|
||||||
|
typ := v.Type()
|
||||||
|
totalExportedFields := 0
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
f := typ.Field(i)
|
||||||
|
if f.PkgPath != "" { // unexported
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.skippedTagPresent(f.Tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
numOfExportedFields, err := p.setNonZero(v.Field(i))
|
||||||
|
totalExportedFields += numOfExportedFields
|
||||||
|
if err != nil {
|
||||||
|
return totalExportedFields, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalExportedFields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNonZero assigns a deterministic non-zero value to a field based on its kind,
|
||||||
|
// recursing into nested structs and populating one element of slice fields.
|
||||||
|
func (p *PopulateFields) setNonZero(field reflect.Value) (int, error) {
|
||||||
|
if f, ok := p.CustomFieldSetters[field.Type()]; ok {
|
||||||
|
return f(p, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
field.SetString("non-zero")
|
||||||
|
case reflect.Bool:
|
||||||
|
field.SetBool(true)
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
field.SetInt(7)
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
field.SetUint(7)
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
field.SetFloat(7)
|
||||||
|
case reflect.Struct:
|
||||||
|
n, err := p.PopulateAll(field)
|
||||||
|
return n + 1, err
|
||||||
|
case reflect.Slice:
|
||||||
|
s := reflect.MakeSlice(field.Type(), 1, 1)
|
||||||
|
_, err := p.setNonZero(s.Index(0))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
field.Set(s)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unhandled field kind %s; extend setNonZero", field.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultCustomFieldSetters() map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) {
|
||||||
|
return map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error){
|
||||||
|
reflect.TypeOf(netip.Prefix{}): func(_ *PopulateFields, field reflect.Value) (int, error) {
|
||||||
|
field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24")))
|
||||||
|
return 1, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PopulateFields) skippedTagPresent(t reflect.StructTag) bool {
|
||||||
|
for tag, value := range p.TagsToSkip {
|
||||||
|
if v := t.Get(tag); v == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package trustedproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List holds a parsed set of trusted upstream proxy prefixes and answers trust
|
||||||
|
// questions against it. The zero value (and a nil *List) is a valid, empty list
|
||||||
|
// that never trusts any address, so callers can use it without a nil check.
|
||||||
|
type List struct {
|
||||||
|
prefixes []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses a comma-separated list of CIDR prefixes or bare IPs into a List.
|
||||||
|
// Bare IPs are converted to single-host prefixes (/32 or /128). An empty input
|
||||||
|
// yields an empty List that trusts nothing.
|
||||||
|
func Parse(raw string) (*List, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return &List{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := netip.ParsePrefix(part)
|
||||||
|
if err == nil {
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, addrErr := netip.ParseAddr(part)
|
||||||
|
if addrErr != nil {
|
||||||
|
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 32
|
||||||
|
if addr.Is6() {
|
||||||
|
bits = 128
|
||||||
|
}
|
||||||
|
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
||||||
|
}
|
||||||
|
return &List{prefixes: prefixes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromPrefixes wraps an already-parsed set of prefixes in a List.
|
||||||
|
func FromPrefixes(prefixes []netip.Prefix) *List {
|
||||||
|
return &List{prefixes: prefixes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty reports whether the list contains no prefixes.
|
||||||
|
func (l *List) Empty() bool {
|
||||||
|
return l == nil || len(l.prefixes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrusted reports whether the given host:port or bare IP falls within the list.
|
||||||
|
func (l *List) IsTrusted(remoteAddr string) bool {
|
||||||
|
if l.Empty() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return l.Contains(ExtractHostIP(remoteAddr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether the given address falls within any trusted prefix.
|
||||||
|
func (l *List) Contains(addr netip.Addr) bool {
|
||||||
|
if l.Empty() || !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range l.prefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the
|
||||||
|
// list. It walks the XFF chain right-to-left, skipping IPs that match trusted
|
||||||
|
// prefixes; the first untrusted IP is the real client. If the list is empty or
|
||||||
|
// remoteAddr is not trusted, it returns the remoteAddr IP directly, ignoring any
|
||||||
|
// forwarding headers.
|
||||||
|
func (l *List) ResolveClientIP(remoteAddr, xff string) netip.Addr {
|
||||||
|
remoteIP := ExtractHostIP(remoteAddr)
|
||||||
|
|
||||||
|
if l.Empty() || !l.Contains(remoteIP) {
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
if xff == "" {
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
ip := strings.TrimSpace(parts[i])
|
||||||
|
if ip == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if !l.Contains(addr) {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if first := strings.TrimSpace(parts[0]); first != "" {
|
||||||
|
if addr, err := netip.ParseAddr(first); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractHostIP parses the IP from a host:port string and returns it unmapped.
|
||||||
|
func ExtractHostIP(hostPort string) netip.Addr {
|
||||||
|
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
||||||
|
return ap.Addr().Unmap()
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package trustedproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want []netip.Prefix
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty string returns empty list",
|
||||||
|
raw: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single CIDR",
|
||||||
|
raw: "10.0.0.0/8",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single bare IPv4",
|
||||||
|
raw: "1.2.3.4",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single bare IPv6",
|
||||||
|
raw: "::1",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comma-separated CIDRs",
|
||||||
|
raw: "10.0.0.0/8, 192.168.1.0/24",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("192.168.1.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed CIDRs and bare IPs",
|
||||||
|
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("1.2.3.4/32"),
|
||||||
|
netip.MustParsePrefix("fd00::/8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace around entries",
|
||||||
|
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing comma produces no extra entry",
|
||||||
|
raw: "10.0.0.0/8,",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid entry",
|
||||||
|
raw: "not-an-ip",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partially invalid",
|
||||||
|
raw: "10.0.0.0/8, garbage",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got.prefixes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsTrusted(t *testing.T) {
|
||||||
|
list, err := Parse("10.0.0.0/8, 192.168.1.0/24, fd00::/8")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
addr string
|
||||||
|
list *List
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil list", "10.0.0.1", nil, false},
|
||||||
|
{"empty list", "10.0.0.1", &List{}, false},
|
||||||
|
{"IP within /8 prefix", "10.1.2.3", list, true},
|
||||||
|
{"IP within /24 prefix", "192.168.1.100", list, true},
|
||||||
|
{"IP outside all prefixes", "203.0.113.50", list, false},
|
||||||
|
{"boundary IP just outside prefix", "192.168.2.1", list, false},
|
||||||
|
{"unparsable IP", "not-an-ip", list, false},
|
||||||
|
{"IPv6 in trusted range", "fd00::1", list, true},
|
||||||
|
{"IPv6 outside range", "2001:db8::1", list, false},
|
||||||
|
{"empty string", "", list, false},
|
||||||
|
{"host:port within prefix", "10.1.2.3:9999", list, true},
|
||||||
|
{"host:port outside prefix", "203.0.113.50:9999", list, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tt.list.IsTrusted(tt.addr))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListResolveClientIP(t *testing.T) {
|
||||||
|
trusted, err := Parse("10.0.0.0/8, 172.16.0.0/12")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
xff string
|
||||||
|
list *List
|
||||||
|
want netip.Addr
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list returns RemoteAddr",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4",
|
||||||
|
list: &List{},
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil list returns RemoteAddr",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4",
|
||||||
|
list: nil,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted RemoteAddr ignores XFF",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4, 10.0.0.1",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr with single client in XFF",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("10.0.0.1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all XFF IPs trusted returns leftmost",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("10.0.0.2"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "XFF with whitespace",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "XFF with empty segments",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50,,10.0.0.2",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-hop with mixed trust",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoteAddr without port",
|
||||||
|
remoteAddr: "10.0.0.1",
|
||||||
|
xff: "203.0.113.50",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tt.list.ResolveClientIP(tt.remoteAddr, tt.xff))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user