diff --git a/client/android/client.go b/client/android/client.go index 99ccdf393..2266ff53d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 91866dcab..7376e59ca 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -121,6 +121,7 @@ type Manager struct { udpTracker *conntrack.UDPTracker icmpTracker *conntrack.ICMPTracker tcpTracker *conntrack.TCPTracker + fragments *fragmentTracker forwarder atomic.Pointer[forwarder.Forwarder] pendingCapture atomic.Pointer[forwarder.PacketCapture] logger *nblog.Logger @@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error { } } +// decodeTransport decodes the transport header of a first fragment (which +// gopacket leaves undecoded) into the decoder and appends its layer type to +// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns +// false if the protocol is unsupported or the header is truncated. +func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool { + var l4 gopacket.DecodingLayer + var layerType gopacket.LayerType + var minLen int + switch proto { + case layers.IPProtocolTCP: + l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20 + case layers.IPProtocolUDP: + l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8 + case layers.IPProtocolICMPv4: + l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8 + case layers.IPProtocolICMPv6: + l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8 + default: + return false + } + + // Reject a fragment too small to hold the full transport header before + // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping + // the decode avoids gopacket allocating an error on the drop path. + if len(payload) < minLen { + return false + } + + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true +} + // Create userspace firewall manager constructor func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { return create(iface, nil, disableServerRoutes, flowLogger, mtu) @@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } + m.fragments = newFragmentTracker(m.logger) + if disableConntrack { log.Info("conntrack is disabled") } else { @@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe } } if err := iface.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } return m, nil @@ -694,6 +733,10 @@ func (m *Manager) resetState() { m.tcpTracker.Close() } + if m.fragments != nil { + m.fragments.Close() + } + if fwder := m.forwarder.Load(); fwder != nil { fwder.SetCapture(nil) fwder.Stop() @@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return true } - // TODO: pass fragments of routed packets to forwarder + // gopacket does not decode the transport header of any IP fragment, so + // fragments take a dedicated path: the first fragment's header is decoded + // and ACL-evaluated here, and the remaining fragments inherit its verdict. if fragment { - if m.logger.Enabled(nblog.LevelTrace) { - if d.decoded[0] == layers.LayerTypeIPv4 { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - } else { - m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) - } - } - return false + return m.filterInboundFragment(d, srcIP, dstIP, size) } + return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size) +} + +// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully +// decoded (non-fragment) inbound packet. It returns true if the packet should +// be dropped. +func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information @@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size) } +// fragmentMeta holds the reassembly identity and layout of an IP fragment, +// extracted uniformly for IPv4 and IPv6. +type fragmentMeta struct { + key fragmentKey + // offset is the fragment offset in 8-byte units (zero for the first + // fragment). + offset uint16 + // moreFragments is the More Fragments bit. A first fragment with it unset is + // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing + // fragments to inherit a verdict, so it must not be recorded. + moreFragments bool + proto layers.IPProtocol + // l4payload is the fragmentable payload of this fragment. For the first + // fragment it starts with the transport header. + l4payload []byte + // headerEndOctets is the first fragment's payload length in 8-byte units: + // the smallest offset a trailing fragment may start at without overlapping + // the inspected transport header. + headerEndOctets uint16 +} + +// fragmentMetadata extracts the fragment identity and layout from a decoded IP +// fragment. It returns false for fragments it can't interpret (e.g. an IPv6 +// fragment header shorter than 8 bytes), which are then dropped. +func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + payload := d.ip4.Payload + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)}, + offset: d.ip4.FragOffset, + moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0, + proto: d.ip4.Protocol, + l4payload: payload, + headerEndOctets: octets(len(payload)), + }, true + + case layers.LayerTypeIPv6: + // IPv6 fragment extension header: 8 bytes, followed by the fragmentable + // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4). + payload := d.ip6.Payload + if len(payload) < 8 { + return fragmentMeta{}, false + } + nextHeader := layers.IPProtocol(payload[0]) + offsetFlags := binary.BigEndian.Uint16(payload[2:4]) + id := binary.BigEndian.Uint32(payload[4:8]) + l4 := payload[8:] + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)}, + offset: offsetFlags >> 3, + moreFragments: offsetFlags&1 != 0, + proto: nextHeader, + l4payload: l4, + headerEndOctets: octets(len(l4)), + }, true + + default: + return fragmentMeta{}, false + } +} + +// octets rounds a byte length up to whole 8-byte units, the granularity of the +// IP fragment offset field. +func octets(nbytes int) uint16 { + return uint16((nbytes + 7) / 8) +} + +// filterInboundFragment decides the fate of an IP fragment. gopacket stops +// decoding at the network layer for every fragment, so the first fragment's +// transport header is decoded and ACL-evaluated here and its verdict recorded; +// the remaining (headerless) fragments inherit that verdict. Anything that +// cannot be tied to an allowed, non-overlapping first fragment is dropped. +func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool { + meta, ok := fragmentMetadata(d, srcIP, dstIP) + if !ok { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP) + } + return true + } + + if meta.offset != 0 { + return m.filterTrailingFragment(meta, srcIP, dstIP) + } + + // A new first fragment supersedes any recorded verdict for this datagram, so + // a re-sent or overlapping offset-zero fragment can't inherit the old one. + m.fragments.poison(meta.key) + + // First fragment: decode its transport header so the ACL can evaluate it. A + // decode failure means the fragment is too small to hold the full transport + // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it. + if !d.decodeTransport(meta.proto, meta.l4payload) { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } + + return m.filterFirstFragment(d, meta, srcIP, dstIP, size) +} + +// filterTrailingFragment applies a recorded first-fragment verdict to a +// non-first fragment. +func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool { + switch m.fragments.verdict(meta.key, meta.offset) { + case fragmentAllow: + return false + case fragmentOverlap: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + default: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } +} + +// filterFirstFragment runs the verdict part of the inbound pipeline on a first +// fragment with its transport header decoded. It mirrors filterInboundDecoded +// but skips DNAT (port rewriting on fragments is unsupported) and forwarder +// injection (fragments are left to the stack to reassemble, not forwarded). +// Allowed fragments have their verdict recorded so the datagram's trailing +// fragments inherit it. +func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool { + if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) { + m.recordFirstFragment(meta) + return false + } + + if m.localipmanager.IsLocalIP(dstIP) { + ruleID, blocked := m.peerACLsBlock(srcIP, d, nil) + if blocked { + m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + m.trackInbound(d, srcIP, dstIP, ruleID, size) + m.recordFirstFragment(meta) + return false + } + + if !m.routingEnabled.Load() { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP) + } + return true + } + if m.nativeRouter.Load() { + m.trackInbound(d, srcIP, dstIP, nil, size) + m.recordFirstFragment(meta) + return false + } + + // TODO: pass fragments of routed packets to the forwarder; until then + // allowed routed fragments go to the native stack. + srcPort, dstPort := getPortsFromPacket(d) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort) + if !pass { + m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + + m.recordFirstFragment(meta) + return false +} + +// recordFirstFragment caches an allowed first fragment's verdict for its +// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are +// complete datagrams with no trailing fragments, so they are not cached and +// cannot exhaust the verdict table. +func (m *Manager) recordFirstFragment(meta fragmentMeta) { + if !meta.moreFragments { + return + } + m.fragments.recordAllowed(meta.key, meta.headerEndOctets) +} + +// storeDropFlow logs and records a netflow drop event for an inbound packet +// denied by the ACLs. msg is the trace format taking rule id, protocol, source +// and destination. +func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) { + pnum := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } + + m.flowLogger.StoreEvent(nftypes.EventFields{ + FlowID: uuid.New(), + Type: nftypes.TypeDrop, + RuleID: ruleID, + Direction: nftypes.Ingress, + Protocol: pnum, + SourceIP: srcIP, + DestIP: dstIP, + SourcePort: srcPort, + DestPort: dstPort, + // TODO: icmp type/code + RxPackets: 1, + RxBytes: uint64(size), + }) +} + // handleLocalTraffic handles local traffic. // If it returns true, the packet should be dropped. func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData) if blocked { - pnum := getProtocolFromPacket(d) - srcPort, dstPort := getPortsFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: pnum, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - proto := getProtocolFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: proto, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 6291eb285..28320ad88 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,7 +5,9 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" "time" @@ -31,6 +33,11 @@ const ( defaultMaxInFlight = 1024 iosReceiveWindow = 16384 iosMaxInFlight = 256 + + // envForceTCPRACK overrides the platform default for gVisor's RACK loss + // detection. Set to a truthy value to force RACK on, or a falsy value to + // force it off, on any platform. + envForceTCPRACK = "NB_FORCE_TCP_RACK" ) type Forwarder struct { @@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow maxInFlight = iosMaxInFlight } + configureTCPRecovery(s) + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) @@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool { logger.Debug1("forwarder: raw %s socket access available", network) return true } + +// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where +// it interacts poorly with the host and collapses throughput on routed TCP +// connections (gVisor issue #9778). Other platforms keep the default. The +// EnvForceTCPRACK environment variable overrides the platform default. +func configureTCPRecovery(s *stack.Stack) { + disableRACK := runtime.GOOS == "windows" + + if val := os.Getenv(envForceTCPRACK); val != "" { + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("parse %s: %v", envForceTCPRACK, err) + } else { + disableRACK = !force + } + } + + if !disableRACK { + return + } + + opt := tcpip.TCPRecovery(0) + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil { + log.Warnf("disable TCP RACK loss detection: %v", err) + return + } + log.Info("forwarder: TCP RACK loss detection disabled") +} diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go new file mode 100644 index 000000000..accc54365 --- /dev/null +++ b/client/firewall/uspfilter/fragment.go @@ -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() +} diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go new file mode 100644 index 000000000..a9e6d2d13 --- /dev/null +++ b/client/firewall/uspfilter/fragment_bench_test.go @@ -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)) + } +} diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go new file mode 100644 index 000000000..6960e4dda --- /dev/null +++ b/client/firewall/uspfilter/fragment_test.go @@ -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") +} diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go index dd8cf29a3..b069301c1 100644 --- a/client/iface/netstack/env.go +++ b/client/iface/netstack/env.go @@ -3,14 +3,31 @@ package netstack import ( - "fmt" + "net" "os" "strconv" log "github.com/sirupsen/logrus" ) -const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" +const ( + EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" + + // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on. + EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT" + + // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to. + // The proxy is a bridge for local host applications into the userspace + // WireGuard netstack, so it binds to loopback by default. Override this only + // when the proxy must be reachable from other hosts (e.g. a container + // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that + // address. + EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS" + + // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless + // overridden via EnvSocks5ListenerAddress. + defaultSocks5Host = "127.0.0.1" +) // IsEnabled todo: move these function to cmd layer func IsEnabled() bool { @@ -18,24 +35,40 @@ func IsEnabled() bool { } func ListenAddr() string { - sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT") + return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort())) +} + +// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to +// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP. +func listenHost() string { + addr := os.Getenv(EnvSocks5ListenerAddress) + if addr == "" { + return defaultSocks5Host + } + if net.ParseIP(addr) == nil { + log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host) + return defaultSocks5Host + } + return addr +} + +// listenPort returns the port the SOCKS5 proxy binds to, defaulting to +// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid. +func listenPort() int { + sPort := os.Getenv(EnvSocks5ListenerPort) if sPort == "" { - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } port, err := strconv.Atoi(sPort) if err != nil { log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } if port < 1 || port > 65535 { log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } - return listenAddr(port) -} - -func listenAddr(port int) string { - return fmt.Sprintf("0.0.0.0:%d", port) + return port } diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go new file mode 100644 index 000000000..1083435a4 --- /dev/null +++ b/client/iface/netstack/env_test.go @@ -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) + } + }) + } +} diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index 8d90fb82f..9dec7cf53 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -299,7 +299,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn UseIDToken: d.providerConfig.UseIDToken, } - err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) + err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) if err != nil { return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index d0df2b122..be64cc6a8 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } @@ -320,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return tokenInfo, nil } +// parseEmailFromIDToken extracts the email (or name) claim from an ID token +// without verifying its signature. The value is best-effort and used only as a +// UX convenience (login hint prefill and display); it never drives an +// authorization decision. The authoritative identity is established server-side +// from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go index 31c81d701..1800584a2 100644 --- a/client/internal/auth/util.go +++ b/client/internal/auth/util.go @@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) { return hex.EncodeToString(buf), nil } -// isValidAccessToken is a simple validation of the access token -func isValidAccessToken(token string, audience string) error { +// validateTokenAudience checks that the token is a well-formed JWT whose +// audience claim matches the expected audience. +// +// It does NOT verify the token's cryptographic signature and therefore must not +// be treated as an authenticity check. The token is obtained by the client +// directly from the IdP token endpoint over TLS, and its signature is verified +// server-side by the management server against the IdP's JWKS +// (see shared/auth/jwt/validator.go). This function is only a client-side +// sanity check that the returned token targets the expected audience. +func validateTokenAudience(token string, audience string) error { if token == "" { return fmt.Errorf("token received is empty") } - encodedClaims := strings.Split(token, ".")[1] - claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("token is not a well-formed JWT") + } + + claimsString, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go new file mode 100644 index 000000000..7f225bb86 --- /dev/null +++ b/client/internal/auth/util_test.go @@ -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) + } + } +} diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 9c0e52af8..3dc29c4dc 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) { return customPort, nil } - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) if err != nil { log.Debugf("failed to bind random port for DNS: %s", err) return 0, err } - addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect - err = probeListener.Close() - if err != nil { + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err = probeListener.Close(); err != nil { log.Debugf("failed to free up DNS port: %s", err) return 0, err } - return addrPort.Port(), nil + return port, nil } diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index f0efd7b22..3fe8a4bb3 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { 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 for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { 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 { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 1ae281d56..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,7 +17,7 @@ import ( 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) if err != nil { 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.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 queryErr == nil { queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 566905985..ca4194690 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -1,6 +1,7 @@ package statemanager import ( + "bytes" "context" "encoding/json" "errors" @@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, var rawStates map[string]json.RawMessage if err := json.Unmarshal(data, &rawStates); err != nil { + if len(bytes.TrimSpace(data)) == 0 { + log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data)) + } else { + log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data)) + } m.handleCorruptedState(deleteCorrupt) return nil, fmt.Errorf("unmarshal states: %w", err) } diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 359a83556..a2f123900 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 432133999..99486839b 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -44,10 +44,25 @@ type Auth struct { // NewAuth instantiate Auth struct and validate the management URL func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, ManagementURL: mgmURL, } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } diff --git a/client/server/server.go b/client/server/server.go index 2b919d58d..8047006fe 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1081,7 +1081,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { 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) return nil, err } @@ -1154,7 +1157,7 @@ func (s *Server) cleanupConnection() error { // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { - return err + log.Errorf("failed to stop engine during cleanup: %v", err) } } diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 20695cb4d..e15330739 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { if peer.IPv6.IsValid() { hostPatterns = append(hostPatterns, peer.IPv6.String()) } - if peer.FQDN != "" { + // Peer FQDNs and hostnames originate from remote peers, so they must be + // validated as plain DNS names before being embedded in the ssh_config + // "Match host" pattern list. This prevents injection of arbitrary + // ssh_config directives via embedded quotes, whitespace, newlines, the + // comma pattern separator, or the "*"/"?" pattern metacharacters. + if domain.IsValidDomainNoWildcard(peer.FQDN) { hostPatterns = append(hostPatterns, peer.FQDN) + } else if peer.FQDN != "" { + log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN) } - if peer.Hostname != "" && peer.Hostname != peer.FQDN { + if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) { hostPatterns = append(hostPatterns, peer.Hostname) + } else if peer.Hostname != "" && peer.Hostname != peer.FQDN { + log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname) } return hostPatterns } diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index 8e6be40a3..f65d0ba6d 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) { "should use Match host with comma-separated patterns") } +func TestManager_HostPatternInjection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // A malicious peer FQDN/hostname attempts to break out of the Match host + // directive and inject arbitrary ssh_config (a ProxyCommand executing a + // command). It must be rejected, not written to the config. + peers := []PeerSSHInfo{ + { + Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x", + IP: netip.MustParseAddr("100.125.1.1"), + FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal", + }, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, + } + + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned", + "injected directive must not appear in generated config") + assert.NotContains(t, configStr, "evil", + "malicious pattern must be dropped entirely") + // The valid peer must still be present, on a single Match host line. + assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"", + "valid peers must survive, injected patterns dropped") +} + func TestManager_ForcedSSHConfig(t *testing.T) { // Set force environment variable t.Setenv(EnvForceSSHConfig, "true") diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go index 18edb2fdf..a3a9641f8 100644 --- a/client/ssh/server/getent_unix.go +++ b/client/ssh/server/getent_unix.go @@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) { // validateGetentInput checks that the input is safe to pass to getent or id. // Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. func validateGetentInput(input string) bool { maxLen := 32 if runtime.GOOS == "linux" { @@ -80,6 +81,10 @@ func validateGetentInput(input string) bool { return false } + if input[0] == '-' { + return false + } + for _, r := range input { if isAllowedGetentChar(r) { continue diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go index e44563b79..a73214e17 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/ssh/server/getent_unix_test.go @@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) { {"numeric UID", "1001", true}, {"dots and underscores", "alice.bob_test", true}, {"hyphen", "alice-bob", true}, + {"leading hyphen rejected", "-i", false}, + {"leading double hyphen rejected", "--no-idn", false}, + {"lone hyphen rejected", "-", false}, {"kerberos principal", "user@REALM", true}, {"samba machine account", "MACHINE$", true}, {"NIS compat", "+user", true}, diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index 4ee8c2740..e8e7108eb 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -1,10 +1,13 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useStatus } from "@/contexts/StatusContext.tsx"; const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; function openUrl(url: string) { Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); @@ -12,7 +15,26 @@ function openUrl(url: string) { export const DaemonOutdatedOverlay = () => { const { t } = useTranslation(); - const { isDaemonOutdated } = useStatus(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + 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; @@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {

{t("daemon.outdated.description")}

+
+

+ {clientVersion === "development" ? ( + + {t("settings.about.clientName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.client", { version: clientVersion }) + )} +

+

+ {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

+
+
-
diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 4dd3eaa7a..62377f1bc 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -28,6 +28,7 @@ type ProfileContextValue = { loaded: boolean; refresh: () => Promise; switchProfile: (id: string) => Promise; + switchProfileNoConnect: (id: string) => Promise; addProfile: (name: string) => Promise; removeProfile: (id: string) => Promise; renameProfile: (id: string, newName: string) => Promise; @@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { [username, refresh], ); + // Manage-profiles variant: switches without connecting, so the user can + // still adjust the management URL before bringing the connection up. + const switchProfileNoConnect = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + // addProfile creates a profile by display name and returns the // daemon-generated ID, so the caller can immediately address it by ID. const addProfile = useCallback( @@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index c1ce2e449..97261ccc9 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -45,7 +45,7 @@ export function ProfilesTab() { activeProfileId, loaded, username, - switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -100,7 +100,7 @@ export function ProfilesTab() { confirmLabel: t("profile.switch.confirm"), }); if (!ok) return; - await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id)); + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); }; const handleDeregister = async (id: string, name: string) => { @@ -129,14 +129,13 @@ export function ProfilesTab() { await guarded(i18next.t("profile.error.createTitle"), async () => { const id = await addProfile(name); // SetConfig is keyed by the new profile's ID, so it writes the - // not-yet-active profile. Write before switching so any reconnect - // targets the right deployment. + // not-yet-active profile before the switch makes it current. if (!isNetbirdCloud(managementUrl)) { await SettingsSvc.SetConfig( new SetConfigParams({ profileName: id, username, managementUrl }), ); } - await switchProfile(id); + await switchProfileNoConnect(id); }); }; diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 2ceb958d4..10e71babb 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -73,6 +73,13 @@ export default function SessionExpirationDialog() { let offCancel: (() => void) | undefined; + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + try { const start = await Session.RequestExtend({ hint: "" }); const uri = start.verificationUriComplete || start.verificationUri; @@ -105,25 +112,22 @@ export default function SessionExpirationDialog() { if (outcome.kind === "cancel") { waitPromise.cancel?.(); waitPromise.catch(() => {}); + resetDialog(); return; } // Another surface owns this flow; keep the dialog open to retry. if (outcome.result.preempted) { + resetDialog(); return; } - - // Close before the popup so the restore can't flash this window back. - WindowManager.CloseSessionExpiration().catch(console.error); + WindowManager.CloseRenewFlow().catch(console.error); } catch (e) { + resetDialog(); await errorDialog({ Title: t("sessionExpiration.extendFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - offCancel?.(); - WindowManager.CloseBrowserLogin().catch(console.error); - setBusy(false); } }, [busy, t]); @@ -139,12 +143,11 @@ export default function SessionExpirationDialog() { }); WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { + setBusy(false); await errorDialog({ Title: t("sessionExpiration.logoutFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - setBusy(false); } }, [busy, t]); diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index fe06abc20..5a8b0d015 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -22,6 +22,9 @@ type WelcomeStepTrayProps = { export function WelcomeStepTray({ onContinue }: Readonly) { const { t } = useTranslation(); const trayScreenshot = trayScreenshotForOS(); + // macOS has no tray — the icon sits in the menu bar, so the copy says so. + const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title"; + const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description"; return ( <> @@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- {t("welcome.title")} + {t(titleKey)} - {t("welcome.description")} + {t(descriptionKey)}
diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 58b5c484f..419358d36 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -8,6 +8,7 @@ {"code": "fr", "displayName": "Français", "englishName": "French"}, {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, - {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"} + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} ] } diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e0d8096d..5e91e8d88 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Suchen Sie NetBird in der Taskleiste" }, + "welcome.titleMac": { + "message": "Suchen Sie NetBird in der Menüleiste" + }, "welcome.description": { "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." }, + "welcome.descriptionMac": { + "message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, "welcome.continue": { "message": "Weiter" }, @@ -1293,10 +1299,13 @@ "message": "Dokumentation" }, "daemon.outdated.title": { - "message": "NetBird-Dienst ist veraltet" + "message": "NetBird Client ist veraltet" }, "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": { "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 42d40ec30..24bbc67ce 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1377,11 +1377,19 @@ }, "welcome.title": { "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": { "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": { "message": "Continue", @@ -1724,12 +1732,16 @@ "description": "Documentation link on the daemon-unavailable overlay." }, "daemon.outdated.title": { - "message": "NetBird Service Is Outdated", - "description": "Title of the overlay shown when the NetBird background service is too old to drive this UI." + "message": "NetBird Client Is Outdated", + "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." }, "daemon.outdated.description": { - "message": "Update the NetBird service to use this app.", - "description": "Body of the daemon-outdated overlay telling the user to upgrade the service." + "message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.", + "description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated." + }, + "daemon.outdated.download": { + "message": "Download Latest", + "description": "Button on the daemon-outdated overlay that opens the download page for the latest release." }, "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.", diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 47faee61f..c036e4f75 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Busque NetBird en su bandeja del sistema" }, + "welcome.titleMac": { + "message": "Busque NetBird en su barra de menús" + }, "welcome.description": { "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." }, + "welcome.descriptionMac": { + "message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, "welcome.continue": { "message": "Continuar" }, @@ -1293,10 +1299,13 @@ "message": "Documentación" }, "daemon.outdated.title": { - "message": "El servicio de NetBird está desactualizado" + "message": "NetBird Client está desactualizado" }, "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": { "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." diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index be0836e93..c6b91fb25 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cherchez NetBird dans votre barre d’état système" }, + "welcome.titleMac": { + "message": "Cherchez NetBird dans votre barre des menus" + }, "welcome.description": { "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." }, + "welcome.descriptionMac": { + "message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, "welcome.continue": { "message": "Continuer" }, @@ -1293,10 +1299,13 @@ "message": "Documentation" }, "daemon.outdated.title": { - "message": "Le service NetBird est obsolète" + "message": "Le Client NetBird est obsolète" }, "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": { "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." diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b54918364..dd5a1af6c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Keresse a NetBirdöt a tálcán" }, + "welcome.titleMac": { + "message": "Keresse a NetBirdöt a menüsorban" + }, "welcome.description": { "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." }, + "welcome.descriptionMac": { + "message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, "welcome.continue": { "message": "Folytatás" }, @@ -1293,10 +1299,13 @@ "message": "Dokumentáció" }, "daemon.outdated.title": { - "message": "A NetBird szolgáltatás elavult" + "message": "A NetBird Kliens elavult" }, "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": { "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." diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 603364fa2..7a2eb610c 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cerchi NetBird nella tray" }, + "welcome.titleMac": { + "message": "Cerchi NetBird nella barra dei menu" + }, "welcome.description": { "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." }, + "welcome.descriptionMac": { + "message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, "welcome.continue": { "message": "Continua" }, @@ -1293,10 +1299,13 @@ "message": "Documentazione" }, "daemon.outdated.title": { - "message": "Il servizio NetBird è obsoleto" + "message": "NetBird Client è obsoleto" }, "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": { "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..326c825bf --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1331 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "未接続" + }, + "tray.status.daemonUnavailable": { + "message": "実行されていません" + }, + "tray.status.error": { + "message": "エラー" + }, + "tray.status.connected": { + "message": "接続済み" + }, + "tray.status.connecting": { + "message": "接続中" + }, + "tray.status.needsLogin": { + "message": "ログインが必要" + }, + "tray.status.loginFailed": { + "message": "ログインに失敗しました" + }, + "tray.status.sessionExpired": { + "message": "セッションが期限切れ" + }, + "tray.session.expiresIn": { + "message": "セッションはあと{remaining}で期限切れ" + }, + "tray.session.unit.lessThanMinute": { + "message": "1分未満" + }, + "tray.session.unit.minute": { + "message": "1分" + }, + "tray.session.unit.minutes": { + "message": "{count}分" + }, + "tray.session.unit.hour": { + "message": "1時間" + }, + "tray.session.unit.hours": { + "message": "{count}時間" + }, + "tray.session.unit.day": { + "message": "1日" + }, + "tray.session.unit.days": { + "message": "{count}日" + }, + "tray.menu.open": { + "message": "NetBird を開く" + }, + "tray.menu.connect": { + "message": "接続" + }, + "tray.menu.disconnect": { + "message": "切断" + }, + "tray.menu.exitNode": { + "message": "出口ノード" + }, + "tray.menu.networks": { + "message": "リソース" + }, + "tray.menu.profiles": { + "message": "プロファイル" + }, + "tray.menu.manageProfiles": { + "message": "プロファイルの管理" + }, + "tray.menu.settings": { + "message": "設定..." + }, + "tray.menu.debugBundle": { + "message": "デバッグバンドルを作成" + }, + "tray.menu.about": { + "message": "ヘルプとサポート" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "ドキュメント" + }, + "tray.menu.troubleshoot": { + "message": "トラブルシューティング" + }, + "tray.menu.downloadLatest": { + "message": "最新バージョンをダウンロード" + }, + "tray.menu.installVersion": { + "message": "バージョン {version} をインストール" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "デーモン: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird を終了" + }, + "notify.daemonOutdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "notify.daemonOutdated.body": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "notify.update.title": { + "message": "NetBird の更新が利用可能" + }, + "notify.update.body": { + "message": "NetBird {version} が利用可能です。" + }, + "notify.update.enforcedSuffix": { + "message": "管理者がこの更新を必須にしています。" + }, + "notify.error.title": { + "message": "エラー" + }, + "notify.error.connect": { + "message": "接続に失敗しました" + }, + "notify.error.disconnect": { + "message": "切断に失敗しました" + }, + "notify.error.switchProfile": { + "message": "{profile} への切り替えに失敗しました" + }, + "notify.error.exitNode": { + "message": "出口ノード {name} の更新に失敗しました" + }, + "notify.sessionExpired.title": { + "message": "NetBird セッションが期限切れ" + }, + "notify.sessionExpired.body": { + "message": "NetBird セッションの有効期限が切れました。もう一度ログインしてください。" + }, + "notify.sessionWarning.title": { + "message": "まもなくセッションが期限切れ" + }, + "notify.sessionWarning.body": { + "message": "NetBird セッションはあと{remaining}で期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "NetBird セッションはまもなく期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.extend": { + "message": "今すぐ延長" + }, + "notify.sessionWarning.dismiss": { + "message": "閉じる" + }, + "notify.sessionWarning.failed": { + "message": "NetBird セッションの延長に失敗しました" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird セッションを延長しました" + }, + "notify.sessionWarning.successBody": { + "message": "セッションが更新されました。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "セッション期限が拒否されました" + }, + "notify.sessionDeadlineRejected.body": { + "message": "サーバーが無効なセッション期限を送信しました。もう一度サインインしてください。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 設定が更新されました" + }, + "notify.mdm.policyApplied.body": { + "message": "NetBird の構成が IT ポリシーによって更新されました。" + }, + "common.cancel": { + "message": "キャンセル" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "変更を保存" + }, + "common.saving": { + "message": "保存中…" + }, + "common.close": { + "message": "閉じる" + }, + "common.copy": { + "message": "コピー" + }, + "common.togglePasswordVisibility": { + "message": "パスワードの表示を切り替え" + }, + "common.increase": { + "message": "増やす" + }, + "common.decrease": { + "message": "減らす" + }, + "common.delete": { + "message": "削除" + }, + "common.create": { + "message": "作成" + }, + "common.add": { + "message": "追加" + }, + "common.remove": { + "message": "削除" + }, + "common.refresh": { + "message": "更新" + }, + "common.loading": { + "message": "読み込み中…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "結果が見つかりませんでした" + }, + "common.noResults.description": { + "message": "結果が見つかりませんでした。別の検索語を試すか、フィルターを変更してください。" + }, + "notConnected.title": { + "message": "未接続" + }, + "notConnected.description": { + "message": "ピア、ネットワークリソース、出口ノードの詳細情報を表示するには、まず NetBird に接続してください。" + }, + "connect.status.disconnected": { + "message": "未接続" + }, + "connect.status.connecting": { + "message": "接続中..." + }, + "connect.status.connected": { + "message": "接続済み" + }, + "connect.status.disconnecting": { + "message": "切断中..." + }, + "connect.status.daemonUnavailable": { + "message": "デーモンが利用できません" + }, + "connect.status.loginRequired": { + "message": "ログインが必要" + }, + "connect.error.loginTitle": { + "message": "ログインに失敗しました" + }, + "connect.error.connectTitle": { + "message": "接続に失敗しました" + }, + "connect.error.disconnectTitle": { + "message": "切断に失敗しました" + }, + "nav.peers.title": { + "message": "ピア" + }, + "nav.peers.description": { + "message": "{total}台中{connected}台接続中" + }, + "nav.resources.title": { + "message": "リソース" + }, + "nav.resources.description": { + "message": "{total}件中{active}件有効" + }, + "nav.exitNode.title": { + "message": "出口ノード" + }, + "nav.exitNode.none": { + "message": "未使用" + }, + "nav.exitNode.using": { + "message": "{name} 経由" + }, + "header.openSettings": { + "message": "設定を開く" + }, + "header.togglePanel": { + "message": "サイドパネルを切り替え" + }, + "profile.selector.loading": { + "message": "読み込み中..." + }, + "profile.selector.noProfile": { + "message": "プロファイルなし" + }, + "profile.selector.searchPlaceholder": { + "message": "名前でプロファイルを検索..." + }, + "profile.selector.emptyTitle": { + "message": "プロファイルが見つかりません" + }, + "profile.selector.emptyDescription": { + "message": "別の検索語を試すか、新しいプロファイルを作成してください。" + }, + "profile.selector.newProfile": { + "message": "新しいプロファイル" + }, + "profile.selector.moreOptions": { + "message": "その他のオプション" + }, + "profile.selector.deregister": { + "message": "登録解除" + }, + "profile.selector.delete": { + "message": "削除" + }, + "profile.selector.switchTo": { + "message": "このプロファイルに切り替え" + }, + "profile.selector.edit": { + "message": "編集" + }, + "profile.edit.title": { + "message": "プロファイルを編集" + }, + "profile.edit.submit": { + "message": "変更を保存" + }, + "profile.dialog.title": { + "message": "プロファイル名を入力" + }, + "profile.dialog.nameLabel": { + "message": "プロファイル名" + }, + "profile.dialog.description": { + "message": "分かりやすいプロファイル名を設定してください。" + }, + "profile.dialog.placeholder": { + "message": "例: 仕事" + }, + "profile.dialog.submit": { + "message": "プロファイルを追加" + }, + "profile.dialog.required": { + "message": "プロファイル名を入力してください(例: 仕事、自宅)" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud または独自のサーバーを使用します。" + }, + "profile.dialog.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのままプロファイルを追加してください。" + }, + "header.menu.settings": { + "message": "設定..." + }, + "header.menu.defaultView": { + "message": "デフォルト表示" + }, + "header.menu.advancedView": { + "message": "詳細表示" + }, + "header.menu.updateAvailable": { + "message": "更新が利用可能" + }, + "header.menu.open": { + "message": "メニューを開く" + }, + "header.profile.switch": { + "message": "プロファイルを切り替え" + }, + "connect.toggle.label": { + "message": "NetBird 接続を切り替え" + }, + "connect.localIp.label": { + "message": "ローカル IP アドレス" + }, + "common.search": { + "message": "検索" + }, + "common.filter": { + "message": "フィルター" + }, + "exitNodes.dropdown.trigger": { + "message": "出口ノードを選択" + }, + "peers.row.label": { + "message": "{name} の詳細を開く、{status}" + }, + "peers.dialog.title": { + "message": "ピアの詳細" + }, + "networks.row.toggle": { + "message": "{name} を切り替え" + }, + "networks.bulk.label": { + "message": "表示中のすべてのリソースを切り替え" + }, + "profile.switch.title": { + "message": "プロファイルを「{name}」に切り替えますか?" + }, + "profile.switch.message": { + "message": "プロファイルを切り替えてもよろしいですか?\n現在のプロファイルは切断されます。" + }, + "profile.switch.confirm": { + "message": "確認" + }, + "profile.deregister.title": { + "message": "プロファイル「{name}」の登録を解除しますか?" + }, + "profile.deregister.message": { + "message": "このプロファイルの登録を解除してもよろしいですか?\n再度使用するにはログインが必要になります。" + }, + "profile.deregister.confirm": { + "message": "登録解除" + }, + "profile.delete.title": { + "message": "プロファイル「{name}」を削除しますか?" + }, + "profile.delete.message": { + "message": "このプロファイルを削除してもよろしいですか?\nこの操作は取り消せません。" + }, + "profile.delete.disabledActive": { + "message": "使用中のプロファイルは削除できません。削除する前に別のプロファイルに切り替えてください。" + }, + "profile.delete.disabledDefault": { + "message": "デフォルトのプロファイルは削除できません。" + }, + "profile.error.switchTitle": { + "message": "プロファイルの切り替えに失敗しました" + }, + "profile.error.deregisterTitle": { + "message": "プロファイルの登録解除に失敗しました" + }, + "profile.error.deleteTitle": { + "message": "プロファイルの削除に失敗しました" + }, + "profile.error.createTitle": { + "message": "プロファイルの作成に失敗しました" + }, + "profile.error.editTitle": { + "message": "プロファイルの編集に失敗しました" + }, + "profile.error.loadTitle": { + "message": "プロファイルの読み込みに失敗しました" + }, + "profile.dropdown.activeProfile": { + "message": "使用中のプロファイル" + }, + "profile.dropdown.switchProfile": { + "message": "プロファイルを切り替え" + }, + "profile.dropdown.noEmail": { + "message": "その他" + }, + "profile.dropdown.addProfile": { + "message": "プロファイルを追加" + }, + "profile.dropdown.manageProfiles": { + "message": "プロファイルの管理" + }, + "profile.dropdown.settings": { + "message": "設定" + }, + "settings.profiles.section.profiles": { + "message": "プロファイル" + }, + "settings.profiles.intro": { + "message": "仕事用と個人用のアカウント、あるいは異なる管理サーバーなど、複数の NetBird ID を並行して管理できます。以下でプロファイルの追加、登録解除、削除ができます。" + }, + "settings.profiles.addProfile": { + "message": "プロファイルを追加" + }, + "settings.profiles.active": { + "message": "使用中" + }, + "settings.profiles.emptyTitle": { + "message": "プロファイルがありません" + }, + "settings.profiles.emptyDescription": { + "message": "NetBird 管理サーバーに接続するプロファイルを作成してください。" + }, + "settings.error.loadTitle": { + "message": "設定の読み込みに失敗しました" + }, + "settings.error.saveTitle": { + "message": "設定の保存に失敗しました" + }, + "settings.error.debugBundleTitle": { + "message": "デバッグバンドルの作成に失敗しました" + }, + "settings.nav.label": { + "message": "設定セクション" + }, + "settings.tabs.general": { + "message": "一般" + }, + "settings.tabs.network": { + "message": "ネットワーク" + }, + "settings.tabs.security": { + "message": "セキュリティ" + }, + "settings.tabs.profiles": { + "message": "プロファイル" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "詳細設定" + }, + "settings.tabs.troubleshooting": { + "message": "トラブルシューティング" + }, + "settings.tabs.about": { + "message": "情報" + }, + "settings.tabs.updateAvailable": { + "message": "更新が利用可能" + }, + "settings.general.section.general": { + "message": "一般" + }, + "settings.general.section.connection": { + "message": "接続" + }, + "settings.general.connectOnStartup.label": { + "message": "起動時に接続" + }, + "settings.general.connectOnStartup.help": { + "message": "サービスの起動時に自動的に接続を確立します。" + }, + "settings.general.notifications.label": { + "message": "デスクトップ通知" + }, + "settings.general.notifications.help": { + "message": "新しい更新や接続イベントに関するデスクトップ通知を表示します。" + }, + "settings.general.autostart.label": { + "message": "ログイン時に NetBird UI を起動" + }, + "settings.general.autostart.help": { + "message": "ログイン時に NetBird インターフェースを自動的に起動します。これはグラフィカルインターフェースにのみ影響し、バックグラウンドサービスには影響しません。" + }, + "settings.general.autostart.errorTitle": { + "message": "自動起動の変更に失敗しました" + }, + "settings.general.language.label": { + "message": "表示言語" + }, + "settings.general.language.help": { + "message": "NetBird インターフェースの言語を選択します。" + }, + "settings.general.language.search": { + "message": "言語を検索…" + }, + "settings.general.language.empty": { + "message": "一致する言語がありません。" + }, + "settings.general.management.label": { + "message": "管理サーバー" + }, + "settings.general.management.help": { + "message": "NetBird Cloud または自身のセルフホスト管理サーバーに接続します。変更するとクライアントが再接続します。" + }, + "settings.general.management.cloud": { + "message": "クラウド" + }, + "settings.general.management.selfHosted": { + "message": "セルフホスト" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "settings.general.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのまま保存してください。" + }, + "settings.general.management.switchCloudTitle": { + "message": "NetBird Cloud に切り替えますか?" + }, + "settings.general.management.switchCloudMessage": { + "message": "セルフホストサーバーが切断されます。\n再度ログインが必要になる場合があります。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "クラウドに切り替え" + }, + "settings.network.section.connectivity": { + "message": "ネットワーク接続" + }, + "settings.network.section.routingDns": { + "message": "ルーティングとDNS" + }, + "settings.network.monitor.label": { + "message": "ネットワーク変更時に再接続" + }, + "settings.network.monitor.help": { + "message": "ネットワークを監視し、Wi-Fiの切り替え、イーサネットの変更、スリープからの復帰などの変化時に自動的に再接続します。" + }, + "settings.network.dns.label": { + "message": "DNSを有効にする" + }, + "settings.network.dns.help": { + "message": "NetBird が管理する DNS 設定をホストのリゾルバに適用します。" + }, + "settings.network.clientRoutes.label": { + "message": "クライアントルートを有効にする" + }, + "settings.network.clientRoutes.help": { + "message": "他のピアからルートを受け入れ、そのネットワークに到達できるようにします。" + }, + "settings.network.serverRoutes.label": { + "message": "サーバールートを有効にする" + }, + "settings.network.serverRoutes.help": { + "message": "このホストのローカルルートを他のピアにアドバタイズします。" + }, + "settings.network.ipv6.label": { + "message": "IPv6を有効にする" + }, + "settings.network.ipv6.help": { + "message": "NetBird オーバーレイネットワークで IPv6 アドレッシングを使用します。" + }, + "settings.security.section.firewall": { + "message": "ファイアウォール" + }, + "settings.security.section.encryption": { + "message": "暗号化" + }, + "settings.security.blockInbound.label": { + "message": "受信トラフィックをブロック" + }, + "settings.security.blockInbound.help": { + "message": "このデバイスおよびこのデバイスがルーティングするネットワークへの、ピアからの要求されていない接続を拒否します。送信トラフィックには影響しません。" + }, + "settings.security.blockLan.label": { + "message": "LANアクセスをブロック" + }, + "settings.security.blockLan.help": { + "message": "このデバイスがピアのトラフィックをルーティングする際に、ピアがローカルネットワークやそのデバイスに到達できないようにします。" + }, + "settings.security.rosenpass.label": { + "message": "量子耐性を有効にする" + }, + "settings.security.rosenpass.help": { + "message": "WireGuard® に加えて Rosenpass によるポスト量子鍵交換を追加します。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "寛容モードを有効にする" + }, + "settings.security.rosenpassPermissive.help": { + "message": "量子耐性に対応していないピアへの接続を許可します。" + }, + "settings.ssh.section.server": { + "message": "サーバー" + }, + "settings.ssh.section.capabilities": { + "message": "機能" + }, + "settings.ssh.section.authentication": { + "message": "認証" + }, + "settings.ssh.server.label": { + "message": "SSHサーバーを有効にする" + }, + "settings.ssh.server.help": { + "message": "このホストで NetBird SSH サーバーを実行し、他のピアが接続できるようにします。" + }, + "settings.ssh.root.label": { + "message": "rootログインを許可" + }, + "settings.ssh.root.help": { + "message": "ピアが root ユーザーとしてサインインできるようにします。無効にすると非特権アカウントが必要になります。" + }, + "settings.ssh.sftp.label": { + "message": "SFTPを許可" + }, + "settings.ssh.sftp.help": { + "message": "ネイティブの SFTP または SCP クライアントを使用してファイルを安全に転送します。" + }, + "settings.ssh.localForward.label": { + "message": "ローカルポート転送" + }, + "settings.ssh.localForward.help": { + "message": "接続するピアが、このホストから到達可能なサービスへローカルポートをトンネリングできるようにします。" + }, + "settings.ssh.remoteForward.label": { + "message": "リモートポート転送" + }, + "settings.ssh.remoteForward.help": { + "message": "接続するピアが、このホスト上のポートを自身のマシンに公開できるようにします。" + }, + "settings.ssh.jwt.label": { + "message": "JWT認証を有効にする" + }, + "settings.ssh.jwt.help": { + "message": "各 SSH セッションを IdP に対して検証し、ユーザー ID と監査を行います。無効にするとネットワークの ACL ポリシーのみに依存します。IdP が利用できない場合に便利です。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWTキャッシュTTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "発信 SSH 接続で再度認証を求めるまでに、このクライアントが JWT をキャッシュする期間です。0 に設定するとキャッシュを無効にし、接続ごとに認証します。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "インターフェース" + }, + "settings.advanced.section.security": { + "message": "セキュリティ" + }, + "settings.advanced.interfaceName.label": { + "message": "名前" + }, + "settings.advanced.interfaceName.error": { + "message": "1〜15文字の英字、数字、ドット、ハイフン、アンダースコアを使用してください。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "「utun」に続けて数字で始まる必要があります(例: utun100)。" + }, + "settings.advanced.port.label": { + "message": "ポート" + }, + "settings.advanced.port.error": { + "message": "{min}〜{max}の範囲でポートを入力してください。" + }, + "settings.advanced.port.help": { + "message": "0 に設定すると、ランダムな空きポートが使用されます。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "{min}〜{max}の範囲で MTU 値を入力してください。" + }, + "settings.advanced.psk.label": { + "message": "事前共有鍵" + }, + "settings.advanced.psk.help": { + "message": "追加の対称暗号化のためのオプションの WireGuard PSK です。NetBird セットアップキーとは異なります。同じ事前共有鍵を使用するピアとのみ通信できます。" + }, + "settings.troubleshooting.section.title": { + "message": "デバッグバンドル" + }, + "settings.troubleshooting.anonymize.label": { + "message": "機密情報を匿名化" + }, + "settings.troubleshooting.anonymize.help": { + "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "システム情報を含める" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS、カーネル、ネットワークインターフェース、ルーティングテーブルを含めます。" + }, + "settings.troubleshooting.upload.label": { + "message": "バンドルを NetBird サーバーにアップロード" + }, + "settings.troubleshooting.upload.help": { + "message": "NetBird サポートと共有するためのアップロードキーを返します。" + }, + "settings.troubleshooting.trace.label": { + "message": "トレースログを有効にする" + }, + "settings.troubleshooting.trace.help": { + "message": "ログレベルを TRACE に引き上げ、その後元に戻します。" + }, + "settings.troubleshooting.capture.label": { + "message": "キャプチャセッション" + }, + "settings.troubleshooting.capture.help": { + "message": "再接続して待機し、問題を再現できるようにします。" + }, + "settings.troubleshooting.packets.label": { + "message": "ネットワークパケットをキャプチャ" + }, + "settings.troubleshooting.packets.help": { + "message": "キャプチャ期間中のネットワークトラフィックを .pcap として保存します。" + }, + "settings.troubleshooting.duration.label": { + "message": "キャプチャ時間" + }, + "settings.troubleshooting.duration.help": { + "message": "キャプチャセッションを実行する時間です。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分" + }, + "settings.troubleshooting.create": { + "message": "バンドルを作成" + }, + "settings.troubleshooting.progress.description": { + "message": "ログ、システムの詳細、接続状態を収集しています。通常はしばらくで完了します。完了するまで NetBird を使い続けても、設定を閉じても構いません。" + }, + "settings.troubleshooting.cancelling": { + "message": "キャンセル中…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "デバッグバンドルのアップロードに成功しました!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "バンドルを保存しました" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "下記のアップロードキーを NetBird サポート と共有してください。ローカルコピーもお使いのデバイスに保存されました。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "デバッグバンドルはローカルに保存されました。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "キーをコピー" + }, + "settings.troubleshooting.done.openFolder": { + "message": "フォルダを開く" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "ファイルの場所を開く" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "アップロードに失敗しました: {reason} バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "アップロードに失敗しました。バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird を再接続しています…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "デバッグログをキャプチャしています" + }, + "settings.troubleshooting.stage.bundling": { + "message": "デバッグバンドルを生成しています…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "NetBird にアップロードしています…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "キャンセル中…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[開発版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved." + }, + "settings.about.links.imprint": { + "message": "運営者情報" + }, + "settings.about.links.privacy": { + "message": "プライバシー" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "利用規約" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "フォーラム" + }, + "settings.about.community.documentation": { + "message": "ドキュメント" + }, + "settings.about.community.feedback": { + "message": "フィードバック" + }, + "update.banner.message": { + "message": "NetBird {version} をインストールする準備ができました。" + }, + "update.banner.later": { + "message": "後で" + }, + "update.banner.installNow": { + "message": "今すぐインストール" + }, + "update.card.versionAvailableDownload": { + "message": "バージョン {version} がダウンロード可能です。" + }, + "update.card.versionAvailableInstall": { + "message": "バージョン {version} がインストール可能です。" + }, + "update.card.whatsNew": { + "message": "新機能は?" + }, + "update.card.installNow": { + "message": "今すぐインストール" + }, + "update.card.getInstaller": { + "message": "ダウンロード" + }, + "update.card.autoCheckInterval": { + "message": "NetBird はバックグラウンドで更新を確認します。" + }, + "update.card.changelog": { + "message": "変更履歴" + }, + "update.card.onLatestVersion": { + "message": "最新バージョンを使用しています" + }, + "update.header.tooltip": { + "message": "更新が利用可能" + }, + "update.overlay.updatingVersion": { + "message": "NetBird を v{version} に更新しています" + }, + "update.overlay.updating": { + "message": "NetBird を更新しています" + }, + "update.overlay.description": { + "message": "新しいバージョンが利用可能で、インストール中です。更新が完了すると NetBird は自動的に再起動します。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新に時間がかかっています" + }, + "update.overlay.error.timeoutDescription": { + "message": "{target} のインストールに時間がかかりすぎ、完了しませんでした。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新が停止されました" + }, + "update.overlay.error.canceledDescription": { + "message": "{target} への更新は完了前にキャンセルされました。" + }, + "update.overlay.error.failTitle": { + "message": "更新をインストールできませんでした" + }, + "update.overlay.error.failDescription": { + "message": "{target} をインストールできませんでした。" + }, + "update.overlay.error.unknownMessage": { + "message": "不明なエラー" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新しいバージョン" + }, + "update.error.loadStateTitle": { + "message": "更新状態の読み込みに失敗しました" + }, + "update.error.triggerTitle": { + "message": "更新の開始に失敗しました" + }, + "update.page.versionLine": { + "message": "クライアントを次のバージョンに更新しています: {version}。" + }, + "update.page.versionLineGeneric": { + "message": "クライアントを更新しています。" + }, + "update.page.outdated": { + "message": "クライアントのバージョンが、管理サーバーで設定された自動更新バージョンより古くなっています。" + }, + "update.page.status.running": { + "message": "更新中" + }, + "update.page.status.timeout": { + "message": "更新がタイムアウトしました。もう一度お試しください。" + }, + "update.page.status.canceled": { + "message": "更新がキャンセルされました。" + }, + "update.page.status.failed": { + "message": "更新に失敗しました: {message}" + }, + "update.page.status.unknownError": { + "message": "不明な更新エラー" + }, + "update.page.failedTitle": { + "message": "更新に失敗しました" + }, + "update.page.timeoutMessage": { + "message": "更新がタイムアウトしました。" + }, + "update.page.dontClose": { + "message": "このウィンドウを閉じないでください。" + }, + "update.page.updating": { + "message": "更新中…" + }, + "update.page.complete": { + "message": "更新が完了しました" + }, + "update.page.failed": { + "message": "更新に失敗しました" + }, + "window.title.settings": { + "message": "設定" + }, + "window.title.signIn": { + "message": "サインイン" + }, + "window.title.sessionExpiration": { + "message": "セッションの期限切れ" + }, + "window.title.updating": { + "message": "更新中" + }, + "window.title.welcome": { + "message": "NetBird へようこそ" + }, + "window.title.error": { + "message": "エラー" + }, + "welcome.title": { + "message": "トレイの NetBird を確認してください" + }, + "welcome.titleMac": { + "message": "メニューバーの NetBird を確認してください" + }, + "welcome.description": { + "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.descriptionMac": { + "message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.continue": { + "message": "続ける" + }, + "welcome.back": { + "message": "戻る" + }, + "welcome.management.title": { + "message": "NetBird をセットアップ" + }, + "welcome.management.description": { + "message": "「続ける」をクリックして開始するか、独自の NetBird サーバーをお持ちの場合は「セルフホスト」を選択してください。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "当社のホスト型サービスを使用します。セットアップは不要です。" + }, + "welcome.management.selfHosted.title": { + "message": "セルフホスト" + }, + "welcome.management.selfHosted.description": { + "message": "独自の管理サーバーに接続します。" + }, + "welcome.management.urlLabel": { + "message": "管理サーバーのURL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "welcome.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLまたはネットワークを確認し、正しいことが確実な場合は続行してください。" + }, + "welcome.management.checking": { + "message": "確認中…" + }, + "browserLogin.title": { + "message": "ブラウザでログインを完了してください" + }, + "browserLogin.notSeeing": { + "message": "サインインを完了できるようブラウザのタブを開きました。表示されませんか?" + }, + "browserLogin.tryAgain": { + "message": "再試行" + }, + "browserLogin.openFailedTitle": { + "message": "ブラウザの起動に失敗しました" + }, + "sessionExpiration.title": { + "message": "まもなくセッションが期限切れになります" + }, + "sessionExpiration.titleLater": { + "message": "セッションが期限切れになります" + }, + "sessionExpiration.description": { + "message": "このデバイスはまもなく切断されます。ブラウザでのサインインで更新してください。" + }, + "sessionExpiration.descriptionLater": { + "message": "ブラウザでサインインすると、このデバイスがネットワークに接続されたままになります。" + }, + "sessionExpiration.stay": { + "message": "セッションを更新" + }, + "sessionExpiration.authenticate": { + "message": "認証" + }, + "sessionExpiration.logout": { + "message": "ログアウト" + }, + "sessionExpiration.expired": { + "message": "セッションが期限切れになりました" + }, + "sessionExpiration.expiredDescription": { + "message": "デバイスが切断されました。再接続するにはブラウザでサインインして認証してください。" + }, + "sessionExpiration.close": { + "message": "閉じる" + }, + "sessionExpiration.extendFailedTitle": { + "message": "セッションの延長に失敗しました" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "ログアウトに失敗しました" + }, + "peers.search.placeholder": { + "message": "名前または IP で検索" + }, + "peers.filter.all": { + "message": "すべて" + }, + "peers.filter.online": { + "message": "オンライン" + }, + "peers.filter.offline": { + "message": "オフライン" + }, + "peers.empty.title": { + "message": "利用可能なピアがありません" + }, + "peers.empty.description": { + "message": "利用可能なピアがないか、いずれのピアにもアクセス権がありません。" + }, + "peers.details.domain": { + "message": "ドメイン" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公開鍵" + }, + "peers.details.connection": { + "message": "接続" + }, + "peers.details.latency": { + "message": "レイテンシ" + }, + "peers.details.lastHandshake": { + "message": "最終ハンドシェイク" + }, + "peers.details.statusSince": { + "message": "最終接続更新" + }, + "peers.details.bytes": { + "message": "バイト" + }, + "peers.details.bytesSent": { + "message": "送信" + }, + "peers.details.bytesReceived": { + "message": "受信" + }, + "peers.details.localIce": { + "message": "ローカル ICE" + }, + "peers.details.remoteIce": { + "message": "リモート ICE" + }, + "peers.details.never": { + "message": "なし" + }, + "peers.details.justNow": { + "message": "たった今" + }, + "peers.details.refresh": { + "message": "更新" + }, + "peers.status.connected": { + "message": "接続済み" + }, + "peers.status.connecting": { + "message": "接続中" + }, + "peers.status.disconnected": { + "message": "未接続" + }, + "peers.details.relayAddress": { + "message": "リレー" + }, + "peers.details.networks": { + "message": "リソース" + }, + "peers.details.relayed": { + "message": "リレー経由" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass 有効" + }, + "networks.search.placeholder": { + "message": "ネットワークまたはドメインで検索" + }, + "networks.filter.all": { + "message": "すべて" + }, + "networks.filter.active": { + "message": "有効" + }, + "networks.filter.overlapping": { + "message": "重複" + }, + "networks.empty.title": { + "message": "利用可能なリソースがありません" + }, + "networks.empty.description": { + "message": "利用可能なネットワークリソースがないか、いずれのリソースにもアクセス権がありません。" + }, + "networks.selected": { + "message": "選択中" + }, + "networks.unselected": { + "message": "未選択" + }, + "networks.ips.heading": { + "message": "解決された IP" + }, + "networks.bulk.selectionCount": { + "message": "{total}件中{selected}件有効" + }, + "networks.bulk.enableAll": { + "message": "すべて有効化" + }, + "networks.bulk.disableAll": { + "message": "すべて無効化" + }, + "exitNodes.search.placeholder": { + "message": "出口ノードを検索" + }, + "exitNodes.none": { + "message": "なし" + }, + "exitNodes.empty.title": { + "message": "利用可能な出口ノードがありません" + }, + "exitNodes.empty.description": { + "message": "このピアと共有されている出口ノードはありません。" + }, + "exitNodes.card.title": { + "message": "出口ノード" + }, + "exitNodes.card.statusActive": { + "message": "有効" + }, + "exitNodes.card.statusInactive": { + "message": "無効" + }, + "exitNodes.dropdown.noneTitle": { + "message": "なし" + }, + "exitNodes.dropdown.noneDescription": { + "message": "出口ノードを使用しない直接接続" + }, + "quickActions.connect": { + "message": "接続" + }, + "quickActions.disconnect": { + "message": "切断" + }, + "daemon.unavailable.title": { + "message": "NetBird サービスが実行されていません" + }, + "daemon.unavailable.description": { + "message": "サービスが実行されると、アプリは自動的に再接続します。" + }, + "daemon.unavailable.docsLink": { + "message": "ドキュメント" + }, + "daemon.outdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "daemon.outdated.description": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "error.jwt_clock_skew": { + "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" + }, + "error.jwt_expired": { + "message": "サインイントークンの有効期限が切れました。もう一度サインインしてください。" + }, + "error.jwt_signature_invalid": { + "message": "サインインに失敗しました: トークンの署名が無効です。管理者にお問い合わせください。" + }, + "error.session_expired": { + "message": "セッションの有効期限が切れました。もう一度サインインしてください。" + }, + "error.invalid_setup_key": { + "message": "セットアップキーがないか、無効です。" + }, + "error.permission_denied": { + "message": "サインインがサーバーによって拒否されました。" + }, + "error.daemon_unreachable": { + "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" + }, + "error.unknown": { + "message": "操作に失敗しました。" + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2ed0a94c5..37b02d5a8 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Procure o NetBird na sua bandeja" }, + "welcome.titleMac": { + "message": "Procure o NetBird na sua barra de menus" + }, "welcome.description": { "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." }, + "welcome.descriptionMac": { + "message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, "welcome.continue": { "message": "Continuar" }, @@ -1293,10 +1299,13 @@ "message": "Documentação" }, "daemon.outdated.title": { - "message": "O serviço NetBird está desatualizado" + "message": "O NetBird Client está desatualizado" }, "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": { "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." diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 6ba7de8cc..b9ae59df2 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Найдите NetBird в системном трее" }, + "welcome.titleMac": { + "message": "Найдите NetBird в строке меню" + }, "welcome.description": { "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." }, + "welcome.descriptionMac": { + "message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, "welcome.continue": { "message": "Продолжить" }, @@ -1293,10 +1299,13 @@ "message": "Документация" }, "daemon.outdated.title": { - "message": "Служба NetBird устарела" + "message": "Клиент NetBird устарел" }, "daemon.outdated.description": { - "message": "Обновите службу NetBird, чтобы использовать это приложение." + "message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение." + }, + "daemon.outdated.download": { + "message": "Скачать последнюю версию" }, "error.jwt_clock_skew": { "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 609344fc0..2141a770d 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "在托盘中查找 NetBird" }, + "welcome.titleMac": { + "message": "在菜单栏中查找 NetBird" + }, "welcome.description": { "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" }, + "welcome.descriptionMac": { + "message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。" + }, "welcome.continue": { "message": "继续" }, @@ -1293,10 +1299,13 @@ "message": "文档" }, "daemon.outdated.title": { - "message": "NetBird 服务版本过旧" + "message": "NetBird 客户端版本过旧" }, "daemon.outdated.description": { - "message": "请更新 NetBird 服务以使用此应用。" + "message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。" + }, + "daemon.outdated.download": { + "message": "下载最新版本" }, "error.jwt_clock_skew": { "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index c27b62d92..727b2473f 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -12,13 +12,15 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" ) -// ProfileSwitcher holds the reconnect policy shared by the tray and React -// frontend so both flip profiles identically. The policy keys off prevStatus -// from DaemonFeed.Get at SwitchActive entry: +// ProfileSwitcher holds the switch policy shared by the tray and React +// frontend so both flip profiles identically. SwitchActive (plain selection: +// header dropdown, tray submenu) always connects after the switch; +// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can +// still adjust the management URL before connecting. prevStatus from +// DaemonFeed.Get at entry only decides the teardown: // -// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint. -// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login. -// Idle → Switch only. +// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first. +// Idle → no Down. type ProfileSwitcher struct { profiles *Profiles connection *Connection @@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon 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 { + return s.switchActive(ctx, p, true) +} + +// SwitchActiveNoConnect switches to the named profile without connecting, +// tearing down any existing connection first. +func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, false) +} + +func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error { prevStatus := "" - if st, err := s.feed.Get(ctx); err == nil { - prevStatus = st.Status - } else { - log.Warnf("profileswitcher: get status: %v", err) + if s.feed != nil { + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } } - wasActive := strings.EqualFold(prevStatus, StatusConnected) || - strings.EqualFold(prevStatus, StatusConnecting) - needsDown := wasActive || + needsDown := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) || strings.EqualFold(prevStatus, StatusNeedsLogin) || strings.EqualFold(prevStatus, StatusLoginFailed) || strings.EqualFold(prevStatus, StatusSessionExpired) - log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v", - p.ProfileName, prevStatus, wasActive, needsDown) + log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v", + p.ProfileName, prevStatus, connect, needsDown) - // Optimistic Connecting paint only when wasActive: those prevStatuses emit - // stale Connected + transient Idle pushes during Down that must be - // suppressed until Up resumes the stream (see DaemonFeed suppression table). - if wasActive { + // Optimistic Connecting paint plus stale-push suppression during Down (see + // DaemonFeed suppression table); also arms the login-watch that pops + // browser-login when the new profile turns out to need SSO. + if connect && s.feed != nil { 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 { - return fmt.Errorf("reconnect %q: %w", p.ProfileName, err) + return fmt.Errorf("connect %q: %w", p.ProfileName, err) } } diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 3316dadaa..1185ec729 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -185,37 +185,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") - // Prefer the main window's screen (multi-monitor); falls back to OS-default centering. - var screen *application.Screen - if s.mainWindow != nil { - if sc, err := s.mainWindow.GetScreen(); err == nil { - screen = sc - } - } opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) // Not always-on-top: it would obscure the browser tab the user logs in through. opts.AlwaysOnTop = false opts.InitialPosition = application.WindowCentered - 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) bl := s.browserLogin - // Red-X close means cancel: emit the event so startLogin() tears down the SSO wait. bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.app.Event.Emit(EventBrowserLoginCancel) s.mu.Lock() - s.browserLogin = nil - s.restoreHiddenWindowsLocked() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == bl + if userClosed { + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() + if userClosed { + s.app.Event.Emit(EventBrowserLoginCancel) + } }) - s.centerWhenReady(s.browserLogin) + s.centerOnCursorScreen(s.browserLogin) return } if uri != "" { s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } + s.centerOnCursorScreen(s.browserLogin) s.browserLogin.Show() s.browserLogin.Focus() - s.centerWhenReady(s.browserLogin) } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -238,6 +239,15 @@ func (s *WindowManager) CloseBrowserLogin() { s.mu.Lock() w := s.browserLogin s.browserLogin = nil + // The WindowClosing hook no-ops on a programmatic close, so restore here — + // but only if a popup was actually open. The frontend calls this even when no + // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, + // or connection.ts's catch path), and hiddenForLogin is shared with + // OpenInstallProgress, so an unconditional restore could re-show windows a + // still-running install-progress is hiding. + if w != nil { + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() if w != nil { w.Close() @@ -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 // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { diff --git a/client/ui/tray.go b/client/ui/tray.go index 700d94098..c4918825f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -30,6 +30,8 @@ const ( statusError = "Error" + quitDownTimeout = 5 * time.Second + urlGitHubRepo = "https://github.com/netbirdio/netbird" urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" urlDocs = "https://docs.netbird.io" @@ -446,11 +448,28 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() menu.Add(t.loc.T("tray.menu.quit")). SetAccelerator("CmdOrCtrl+Q"). - OnClick(func(*application.Context) { t.app.Quit() }) + OnClick(func(*application.Context) { t.handleQuit() }) return menu } +func (t *Tray) handleQuit() { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } + t.app.Quit() +} + // handleConnect receives the clicked item from the buildMenu closure — // t.upItem is menuMu-guarded and must not be read here. func (t *Tray) handleConnect(upItem *application.MenuItem) { diff --git a/combined/cmd/config.go b/combined/cmd/config.go index fe350e52a..fcbc60dc9 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -145,6 +145,7 @@ type AuthConfig struct { CLIRedirectURIs []string `yaml:"cliRedirectURIs"` Owner *AuthOwnerConfig `yaml:"owner,omitempty"` DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"` + GrantTypes []string `yaml:"grantTypes"` } // AuthStorageConfig contains auth storage settings @@ -604,6 +605,7 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs, + GrantTypes: mgmt.Auth.GrantTypes, } if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 31e0580fb..2b7956f11 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool } hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) relayCfg := relayServer.Config{ Meter: s.metricsServer.Meter, diff --git a/go.mod b/go.mod index b90b68446..ca798decc 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 - github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 @@ -303,7 +303,6 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/wailsapp/wails/webview2 v1.0.27 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.3 // indirect @@ -335,7 +334,7 @@ replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 +replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 diff --git a/go.sum b/go.sum index ad43dc109..58e30a580 100644 --- a/go.sum +++ b/go.sum @@ -476,8 +476,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= @@ -660,10 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60= -github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns= -github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/idp/dex/config.go b/idp/dex/config.go index 56ed998c2..9e56eb6c0 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -613,6 +613,10 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes } + if len(c.OAuth2.GrantTypes) > 0 { + cfg.AllowedGrantTypes = c.OAuth2.GrantTypes + } + // Apply expiry settings if c.Expiry.IDTokens != "" { if d, err := parseDuration(c.Expiry.IDTokens); err == nil { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 67aeb995f..c0b705f13 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -21,7 +21,7 @@ import ( "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/sql" - jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 3eb29db97..0fce1b2c9 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -595,3 +595,90 @@ enablePasswordDB: true assert.True(t, cfg.ContinueOnConnectorFailure, "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") } + +func TestToServerConfig_WiresGrantTypes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-grants-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + grants := []string{"authorization_code", "refresh_token"} + cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}} + assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes) + + empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"} + assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes) +} + +func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "dex-devguard-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + yamlContent := ` +issuer: http://localhost:5599/oauth2 +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5599 +enablePasswordDB: true +` + grantTypesYAML + + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644)) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(context.Background(), yamlConfig) + require.NoError(t, err) + t.Cleanup(func() { _ = provider.Stop(context.Background()) }) + return provider +} + +func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) { + provider := newDeviceGuardProvider(t, ` +oauth2: + grantTypes: + - authorization_code + - refresh_token +`) + + devicePaths := []string{ + "/oauth2/device", + "/oauth2/device/code", + "/oauth2/device/token", + "/oauth2/device/auth/verify_code", + "/oauth2/device/callback", + } + for _, path := range devicePaths { + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path) + } + } + + req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) { + provider := newDeviceGuardProvider(t, "") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.NotEqual(t, http.StatusNotFound, rec.Code) +} diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 962b30250..f82cffae6 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -197,6 +197,12 @@ type JSONMetadataInjection struct { // enforces a 128-char limit per value; oversized values are // truncated rather than failing the request. 0 disables the cap. MaxValueLength int + // Sanitize, when true, replaces characters outside the destination's + // accepted set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to a limited character + // class, so unsanitized group display names (e.g. containing spaces) would + // make Bedrock reject the request with 400. + Sanitize bool } // providers is the canonical list of supported Agent Network providers. @@ -329,6 +335,18 @@ var providers = []Provider{ {ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000}, {ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000}, }, + // Bedrock accepts a cost-allocation metadata header; stamp the caller's + // user + authorizing group so spend can be attributed in AWS Cost + // Management. Sanitized because Bedrock restricts the value character set. + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + }, }, { ID: "vertex_ai_api", diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 74ac91845..95fe91773 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -540,6 +540,7 @@ type identityInjectJSONMetadata struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + Sanitize bool `json:"sanitize,omitempty"` } // buildIdentityInjectConfigJSON walks the enabled providers and emits @@ -583,9 +584,11 @@ func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[s func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) { rule := identityInjectProvider{ProviderID: p.ID} // Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the - // shape silently when the catalog entry doesn't declare one — extras - // can still apply, see below. - if entry.IdentityInjection != nil { + // shape silently when the catalog entry doesn't declare one, or when the + // operator disabled metadata for this provider — extras can still apply, + // see below. MetadataDisabled suppresses only the identity dimensions + // (user + authorizing group), not the catalog's routing ExtraHeaders. + if !p.MetadataDisabled && entry.IdentityInjection != nil { switch { case entry.IdentityInjection.HeaderPair != nil: rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair) @@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject UserKey: userKey, GroupsKey: groupsKey, MaxValueLength: jm.MaxValueLength, + Sanitize: jm.Sanitize, } } diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 9d55bddf1..206d1d12a 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -698,6 +698,94 @@ func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T) "same fixed-schema guarantee for the groups dimension") } +// TestSynthesizeServices_IdentityInject_Bedrock pins Bedrock's cost-allocation +// metadata: a JSONMetadata shape emitting X-Amzn-Bedrock-Request-Metadata with +// the reserved user/group keys, sanitized to Bedrock's accepted charset. +func TestSynthesizeServices_IdentityInject_Bedrock(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Bedrock uses the JSONMetadata shape for cost-allocation metadata") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", entry.JSONMetadata.Header, + "the caller identity lands in Bedrock's cost-allocation metadata header") + assert.Equal(t, "user", entry.JSONMetadata.UserKey) + assert.Equal(t, "group", entry.JSONMetadata.GroupsKey) + assert.True(t, entry.JSONMetadata.Sanitize, + "Bedrock restricts the metadata value charset, so values must be sanitized") +} + +// TestSynthesizeServices_MetadataDisabled_SuppressesInjection verifies the +// per-provider opt-out: a provider with MetadataDisabled set emits no +// identity-inject entry (Bedrock has no catalog ExtraHeaders, so the whole +// entry is dropped). +func TestSynthesizeServices_MetadataDisabled_SuppressesInjection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.MetadataDisabled = true + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + assert.Empty(t, injectCfg.Providers, + "metadata_disabled must drop the provider's identity-inject entry") +} + // TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI // Gateway's wiring: HeaderPair shape with fixed wire names dictated // by Vercel's Custom Reporting API (ai-reporting-user / diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index 2e3195481..b3287168e 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -51,6 +51,12 @@ type Provider struct { // private or self-signed certificate. The synthesiser propagates it into // the router route so the proxy dials that provider's upstream insecurely. SkipTLSVerification bool `gorm:"column:skip_tls_verification"` + // MetadataDisabled suppresses identity metadata injection for this provider. + // Metadata (the caller's user + authorizing group) is injected by default; + // when true the synthesiser omits the provider's identity-inject shape, so no + // user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are + // stamped. Catalog ExtraHeaders (routing config) are unaffected. + MetadataDisabled bool `gorm:"column:metadata_disabled"` // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the // synthesised reverse-proxy service uses to sign / verify session // JWTs after a successful OIDC handshake. Generated once on @@ -137,6 +143,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { if req.SkipTlsVerification != nil { p.SkipTLSVerification = *req.SkipTlsVerification } + if req.MetadataDisabled != nil { + p.MetadataDisabled = *req.MetadataDisabled + } // Identity-header overrides for catalogs flagged Customizable. // nil pointer = "field omitted on the wire" → leave the stored // value untouched (per the openapi description). Empty string is @@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { Models: models, Enabled: p.Enabled, SkipTlsVerification: p.SkipTLSVerification, + MetadataDisabled: p.MetadataDisabled, CreatedAt: &created, UpdatedAt: &updated, } diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go index 1195499e7..f9756bb8b 100644 --- a/management/internals/modules/agentnetwork/types/provider_test.go +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -42,3 +42,38 @@ func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) { assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification") assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value") } + +// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response +// mapping of metadata_disabled, with the same update semantics: nil preserves, +// explicit false clears. +func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "bedrock_api", + Name: "bedrock", + UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + } + } + + p := NewProvider("acc-1") + + req := base() + req.MetadataDisabled = &enable + p.FromAPIRequest(req) + assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field") + assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it") + + // Explicit false clears it (re-enables metadata). + req = base() + req.MetadataDisabled = &disable + p.FromAPIRequest(req) + assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled") + assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value") +} diff --git a/management/main.go b/management/main.go index ff8482f97..a19b741a9 100644 --- a/management/main.go +++ b/management/main.go @@ -1,19 +1,24 @@ package main import ( - "log" "net/http" // nolint:gosec _ "net/http/pprof" "os" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/cmd" ) func main() { - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() + if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { + log.Infof("pprof enabled, listening on: %s", pprofAddr) + go func() { + log.Println(http.ListenAndServe(pprofAddr, nil)) + }() + } + if err := cmd.Execute(); err != nil { os.Exit(1) } diff --git a/management/server/account_test.go b/management/server/account_test.go index 585e267d5..ee910630a 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te } // Sanity: an actually different range still triggers reallocation. - newRange := netip.MustParsePrefix("100.99.0.0/16") + newRange := netip.MustParsePrefix("100.60.0.0/16") _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ PeerLoginExpirationEnabled: true, PeerLoginExpiration: types.DefaultPeerLoginExpiration, diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 821e6ff55..029749a25 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -76,6 +76,9 @@ type EmbeddedIdPConfig struct { DashboardPostLogoutRedirectURIs []string // StaticConnectors are additional connectors to seed during initialization StaticConnectors []dex.Connector + // GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the + // device_code grant to disable the device flow; keep authorization_code and refresh_token. + GrantTypes []string } // EmbeddedStorageConfig holds storage configuration for the embedded IdP. @@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { }, OAuth2: dex.OAuth2{ SkipApprovalScreen: true, + GrantTypes: c.GrantTypes, }, Frontend: dex.Frontend{ Issuer: "NetBird", diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index f3e24298d..bb1650d54 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -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_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, - settings_dashboard_features, + settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, + settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1632,6 +1633,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool sDashboardFeatures sql.NullString + autoUpdateVersion sql.NullString + autoUpdateAlways sql.NullBool + peerExposeEnabled sql.NullBool + peerExposeGroups sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1655,7 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, - &sDashboardFeatures, + &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, + &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1747,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sIPv6EnabledGroups.Valid { _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) } + if autoUpdateAlways.Valid { + account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool + } + if autoUpdateVersion.Valid { + account.Settings.AutoUpdateVersion = autoUpdateVersion.String + } + if peerExposeEnabled.Valid { + account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool + } + if peerExposeGroups.Valid { + _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) + } if sExtraPeerApprovalEnabled.Valid { account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 58f62be32..258e1aaa0 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -9,6 +9,7 @@ import ( "net" "net/netip" "os" + "reflect" "runtime" "sort" "sync" @@ -34,6 +35,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" "github.com/netbirdio/netbird/util/crypt" ) @@ -296,6 +298,53 @@ func Test_SaveAccount(t *testing.T) { }) } +func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + es := types.ExtraSettings{} + reflectedEs := reflect.ValueOf(&es).Elem() + n, err := this.PopulateAll(reflectedEs) + if err != nil { + return n, err + } + field.Set(reflectedEs.Addr()) + return n, nil + }).WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + t := true + df := types.DashboardFeatures{AgentNetwork: &t} + reflectedDf := reflect.ValueOf(&df).Elem() + field.Set(reflectedDf.Addr()) + return 1, nil + }).WithSkippedTag("gorm", "-") + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + + settings := types.Settings{} + numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) + assert.NoError(t, err) + assert.Equal(t, 27, numOfExportedFields) + account.Settings = &settings + + err = store.SaveAccount(context.Background(), account) + assert.NoError(t, err) + + accountFromDb, err := store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err) + assert.NotNil(t, accountFromDb) + assert.NotNil(t, accountFromDb.Settings) + + assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") + }) +} + func TestSqlite_DeleteAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index b5514e19b..a3f2d15e9 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -7,6 +7,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/netbirdio/netbird/client/ssh/auth" @@ -42,6 +43,14 @@ type NetworkMapComponents struct { PostureFailedPeers map[string]map[string]struct{} RouterPeers map[string]*nbpeer.Peer + + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry +} + +type routeIndexEntry struct { + route *route.Route + viaGroup bool } type AccountSettingsInfo struct { @@ -530,33 +539,43 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute disabledRoutes = append(disabledRoutes, r) } - for _, r := range c.Routes { - for _, groupID := range r.PeerGroups { - group := c.GetGroupInfo(groupID) - if group == nil { - continue - } - for _, id := range group.Peers { - if id != peerID { - continue - } - - newPeerRoute := r.Copy() - newPeerRoute.Peer = id - newPeerRoute.PeerGroups = nil - newPeerRoute.ID = route.ID(string(r.ID) + ":" + id) - takeRoute(newPeerRoute) - break - } - } - if r.Peer == peerID { - takeRoute(r.Copy()) + for _, entry := range c.routesByPeer()[peerID] { + if entry.viaGroup { + newPeerRoute := entry.route.Copy() + newPeerRoute.PeerGroups = nil + newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID) + takeRoute(newPeerRoute) + continue } + takeRoute(entry.route.Copy()) } return enabledRoutes, disabledRoutes } +func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry { + c.routesByPeerOnce.Do(func() { + idx := make(map[string][]routeIndexEntry) + for _, r := range c.Routes { + for _, groupID := range r.PeerGroups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + for _, id := range group.Peers { + idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true}) + } + } + if r.Peer != "" { + idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r}) + } + } + c.routesByPeerIdx = idx + }) + + return c.routesByPeerIdx +} + func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { var filteredRoutes []*route.Route for _, r := range routes { diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 01e342c0e..976984256 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -14,6 +14,7 @@ COPY proxy ./proxy COPY route ./route COPY shared ./shared COPY sharedsock ./sharedsock +COPY trustedproxy ./trustedproxy COPY upload-server ./upload-server COPY util ./util COPY version ./version diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index ad8e1b7c0..9b180a5c4 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy" nbacme "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) } - parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies) + parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies) if err != nil { return fmt.Errorf("invalid --trusted-proxies: %w", err) } diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index db868b4e0..d47c71ca4 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -66,7 +67,7 @@ type denyBucket struct { type Logger struct { client gRPCClient logger *log.Logger - trustedProxies []netip.Prefix + trustedProxies *trustedproxy.List usageMux sync.Mutex domainUsage map[string]*domainUsage @@ -82,7 +83,7 @@ type Logger struct { // NewLogger creates a new access log Logger. The trustedProxies parameter // configures which upstream proxy IP ranges are trusted for extracting // the real client IP from X-Forwarded-For headers. -func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger { +func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger { if logger == nil { logger = log.StandardLogger() } diff --git a/proxy/internal/accesslog/requestip.go b/proxy/internal/accesslog/requestip.go index 30c483fd9..71cea85d0 100644 --- a/proxy/internal/accesslog/requestip.go +++ b/proxy/internal/accesslog/requestip.go @@ -4,13 +4,13 @@ import ( "net/http" "net/netip" - "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/trustedproxy" ) // extractSourceIP resolves the real client IP from the request using trusted // proxy configuration. When trustedProxies is non-empty and the direct // connection is from a trusted source, it walks X-Forwarded-For right-to-left // skipping trusted IPs. Otherwise it returns RemoteAddr directly. -func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr { - return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies) +func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr { + return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) } diff --git a/proxy/internal/llm/bedrock_model.go b/proxy/internal/llm/bedrock_model.go new file mode 100644 index 000000000..a4c4704f7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model.go @@ -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, "") +} diff --git a/proxy/internal/llm/bedrock_model_test.go b/proxy/internal/llm/bedrock_model_test.go new file mode 100644 index 000000000..3bd9662b7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model_test.go @@ -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) + } +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go index 8594c392d..8a5314337 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go @@ -64,6 +64,11 @@ type JSONMetadataRule struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + // Sanitize replaces characters outside the destination provider's accepted + // set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to [A-Za-z0-9 +-=._:/@]; + // group display names with other characters would otherwise 400. + Sanitize bool `json:"sanitize,omitempty"` } // Config is the on-wire configuration accepted by the factory. An diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go index ee3f1c20d..722588a15 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -292,15 +292,21 @@ func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware mutations := &middleware.Mutations{} mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header) + emit := func(v string) string { + if rule.Sanitize { + v = sanitizeMetadataValue(v) + } + return truncate(v, rule.MaxValueLength) + } payload := map[string]string{} if rule.UserKey != "" { if identity := identityFor(in); identity != "" { - payload[rule.UserKey] = truncate(identity, rule.MaxValueLength) + payload[rule.UserKey] = emit(identity) } } if rule.GroupsKey != "" { if csv := authorisingTagsCSV(in); csv != "" { - payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength) + payload[rule.GroupsKey] = emit(csv) } } if len(payload) == 0 { @@ -359,6 +365,36 @@ func truncate(s string, maxBytes int) string { return s[:maxBytes] } +// sanitizeMetadataValue replaces any character outside AWS Bedrock's accepted +// request-metadata class — letters, digits, space, and + - = . _ : / @ — with +// '_'. This keeps values (notably the groups CSV, whose commas are rejected, and +// group display names with arbitrary characters) from making Bedrock reject the +// request with 400. The result stays opaque to the gateway. +func sanitizeMetadataValue(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if metadataCharAllowed(r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +func metadataCharAllowed(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + } + switch r { + case ' ', '+', '-', '=', '.', '_', ':', '/', '@': + return true + } + return false +} + // tagsIDsFromAuthorising reads llm_router's authorising-groups metadata // (a CSV of group ids) and returns the parsed slice. Returns nil when // the key is absent or empty so the caller can fall back to the full diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go index aab1271d8..8ec0930b5 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -304,6 +304,46 @@ func TestInject_JSONMetadata_TruncatesValues(t *testing.T) { "per-value byte length must be capped at MaxValueLength") } +// TestInject_JSONMetadata_Sanitize pins the AWS-Bedrock sanitization path: when +// Sanitize is set, characters outside Bedrock's accepted metadata class +// (notably the groups CSV comma and arbitrary characters in group display +// names) are replaced with '_' so Bedrock doesn't reject the request. Allowed +// characters (letters, digits, spaces, and @ . _ : / + - =) pass through. +func TestInject_JSONMetadata_Sanitize(t *testing.T) { + rule := ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + } + mw := New(Config{Providers: []ProviderInjection{rule}}) + in := newInput(portkeyProvider, "alice", []string{"g1", "g2"}) + in.UserEmail = "alice@example.com" + // Group display names carry characters Bedrock rejects (comma, '#'); the CSV + // join adds another comma between the two groups. + in.UserGroupNames = []string{"Eng,Team", "Ops#1"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", added.Key, + "the Bedrock cost-allocation header carries the metadata JSON") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["user"], + "'@' and '.' are in Bedrock's accepted set and must be preserved") + assert.NotContains(t, payload["group"], ",", "commas must be sanitized — Bedrock rejects them") + assert.NotContains(t, payload["group"], "#", "disallowed characters must be sanitized") + assert.Contains(t, payload["group"], "Eng", "allowed characters must be preserved") +} + // TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the // anti-spoof Remove still fires when there's nothing to stamp. func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go new file mode 100644 index 000000000..40cbcb6bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go @@ -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") +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 2aaeb1089..2d987eef6 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -23,6 +23,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/middleware" ) @@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool { if candidate == model { return true } + // Bedrock request models reach the router already normalized (the parser + // strips the region / inference-profile prefix and version suffix), but + // the operator may register the raw inference-profile id (e.g. + // "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides + // compare equal; otherwise a native Bedrock request denies as not-routable. + if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { + return true + } } return false } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 835a1c0b2..9150c0329 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) type ReverseProxy struct { @@ -29,10 +30,10 @@ type ReverseProxy struct { // forwardedProto overrides the X-Forwarded-Proto header value. // Valid values: "auto" (detect from TLS), "http", "https". forwardedProto string - // trustedProxies is a list of IP prefixes for trusted upstream proxies. - // When the direct connection comes from a trusted proxy, forwarding - // headers are preserved and appended to instead of being stripped. - trustedProxies []netip.Prefix + // trustedProxies is the set of trusted upstream proxies. When the direct + // connection comes from a trusted proxy, forwarding headers are preserved + // and appended to instead of being stripped. + trustedProxies *trustedproxy.List mappingsMux sync.RWMutex mappings map[string]Mapping logger *log.Logger @@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option { // between requested URLs and targets. // The internal mappings can be modified using the AddMapping // and RemoveMapping functions. -func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy { +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy { if logger == nil { logger = log.StandardLogger() } @@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool { if !types.IsOverlayOrigin(r.Context()) { return false } - srcIP := extractHostIP(r.RemoteAddr) + srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr) if !srcIP.IsValid() { return false } @@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost stampNetBirdIdentity(r) - clientIP := extractHostIP(r.In.RemoteAddr) + clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr) - if isTrustedAddr(clientIP, p.trustedProxies) { + if p.trustedProxies.Contains(clientIP) { p.setTrustedForwardingHeaders(r, clientIP) } else { p.setUntrustedForwardingHeaders(r, clientIP) @@ -664,7 +665,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" { r.Out.Header.Set("X-Real-IP", realIP) } else { - resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies) + resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For")) r.Out.Header.Set("X-Real-IP", resolved.String()) } diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 9bd427056..83afee387 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) func TestRewriteFunc_HostRewriting(t *testing.T) { @@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr)) + assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr)) }) } } @@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) { func TestRewriteFunc_TrustedProxy(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") - trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) t.Run("appends to X-Forwarded-For", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} diff --git a/proxy/internal/proxy/trustedproxy.go b/proxy/internal/proxy/trustedproxy.go deleted file mode 100644 index 0fe693f90..000000000 --- a/proxy/internal/proxy/trustedproxy.go +++ /dev/null @@ -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 -} diff --git a/proxy/internal/proxy/trustedproxy_test.go b/proxy/internal/proxy/trustedproxy_test.go deleted file mode 100644 index 35ed1f5c2..000000000 --- a/proxy/internal/proxy/trustedproxy_test.go +++ /dev/null @@ -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)) - }) - } -} diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index 0d4aded9c..f8c74d8b5 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -2,13 +2,13 @@ package proxy import ( "context" - "net/netip" "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" ) // Config bundles every knob the proxy reads at construction time. It mirrors @@ -83,9 +83,9 @@ type Config struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to // backends. Valid values: "auto", "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream - // proxies that may set forwarding headers. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies that may set + // forwarding headers. + TrustedProxies *trustedproxy.List // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 diff --git a/proxy/proxyprotocol_test.go b/proxy/proxyprotocol_test.go index fe2fe7e2d..9e19314ed 100644 --- a/proxy/proxyprotocol_test.go +++ b/proxy/proxyprotocol_test.go @@ -10,12 +10,14 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/trustedproxy" ) func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}), ProxyProtocol: true, } @@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ diff --git a/proxy/server.go b/proxy/server.go index f28d580bd..4f448e4b8 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -67,6 +67,7 @@ import ( "github.com/netbirdio/netbird/proxy/web" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util/embeddedroots" ) @@ -79,19 +80,19 @@ type portRouter struct { type Server struct { ctx context.Context - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager staticCertWatcher *certwatch.Watcher - auth *auth.Middleware - http *http.Server - https *http.Server - debug *http.Server - healthServer *health.Server - healthChecker *health.Checker - meter *proxymetrics.Metrics - accessLog *accesslog.Logger + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger // middlewareManager drives per-target middleware dispatch. Always // constructed during boot; an empty registry produces empty chains and // the reverse-proxy stays on the no-capture fast path. @@ -99,16 +100,16 @@ type Server struct { // middlewareRegistry is the source of registered middleware factories. // Concrete middlewares register themselves through init(). middlewareRegistry *middleware.Registry - mainRouter *nbtcp.Router - mainPort uint16 - udpMu sync.Mutex - udpRelays map[types.ServiceID]*udprelay.Relay - udpRelayWg sync.WaitGroup - portMu sync.RWMutex - portRouters map[uint16]*portRouter - svcPorts map[types.ServiceID][]uint16 - lastMappings map[types.ServiceID]*proto.ProxyMapping - portRouterWg sync.WaitGroup + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) // so they can be closed during graceful shutdown, since http.Server.Shutdown @@ -192,10 +193,10 @@ type Server struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to backends. // Valid values: "auto" (detect from TLS), "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream proxies. - // When set, forwarding headers from these sources are preserved and - // appended to instead of being stripped. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies. When set, + // forwarding headers from these sources are preserved and appended to + // instead of being stripped. + TrustedProxies *trustedproxy.List // WireguardPort is the port for the NetBird tunnel interface. Use 0 // for a random OS-assigned port. A fixed port only works with // single-account deployments; multiple accounts will fail to bind @@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener { Listener: ln, ReadHeaderTimeout: proxyProtoHeaderTimeout, } - if len(s.TrustedProxies) > 0 { + if !s.TrustedProxies.Empty() { ppListener.ConnPolicy = s.proxyProtocolPolicy } else { s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers") @@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr addr = addr.Unmap() // called per accept - for _, prefix := range s.TrustedProxies { - if prefix.Contains(addr) { - return proxyproto.REQUIRE, nil - } + if s.TrustedProxies.Contains(addr) { + return proxyproto.REQUIRE, nil } return proxyproto.IGNORE, nil } diff --git a/proxy/trustedproxy.go b/proxy/trustedproxy.go deleted file mode 100644 index 3a1f0ad37..000000000 --- a/proxy/trustedproxy.go +++ /dev/null @@ -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 -} diff --git a/proxy/trustedproxy_test.go b/proxy/trustedproxy_test.go deleted file mode 100644 index 974e56863..000000000 --- a/proxy/trustedproxy_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/relay/cmd/root.go b/relay/cmd/root.go index b1949ca11..a64812d4d 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -45,6 +46,9 @@ type Config struct { LogLevel string LogFile string HealthcheckListenAddress string + // TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose + // X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers. + TrustedProxies string // STUN server configuration EnableSTUN bool STUNPorts []int @@ -116,6 +120,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level") rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file") rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server") + rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address") rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server") rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)") rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)") @@ -155,8 +160,15 @@ func execute(cmd *cobra.Command, args []string) error { return fmt.Errorf("setup metrics: %v", err) } + trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies) + if err != nil { + log.Debugf("failed to parse trusted proxies: %s", err) + return fmt.Errorf("failed to parse trusted proxies: %s", err) + } + srvListenerCfg := server.ListenerConfig{ - Address: cobraConfig.ListenAddress, + Address: cobraConfig.ListenAddress, + TrustedProxies: trustedProxies, } tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig) @@ -173,7 +185,7 @@ func execute(cmd *cobra.Command, args []string) error { } hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) cfg := server.Config{ Meter: metricsServer.Meter, diff --git a/relay/server/handshake.go b/relay/server/handshake.go index 067888406..f064b3501 100644 --- a/relay/server/handshake.go +++ b/relay/server/handshake.go @@ -5,14 +5,8 @@ import ( "fmt" "time" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay/messages" - //nolint:staticcheck - "github.com/netbirdio/netbird/shared/relay/messages/address" - //nolint:staticcheck - authmsg "github.com/netbirdio/netbird/shared/relay/messages/auth" ) const ( @@ -23,55 +17,30 @@ const ( type Validator interface { Validate(any) error - // Deprecated: Use Validate instead. - ValidateHelloMsgType(any) error } -// preparedMsg contains the marshalled success response messages +// preparedMsg contains the marshalled success response message type preparedMsg struct { - responseHelloMsg []byte - responseAuthMsg []byte + responseAuthMsg []byte } func newPreparedMsg(instanceURL string) (*preparedMsg, error) { - rhm, err := marshalResponseHelloMsg(instanceURL) - if err != nil { - return nil, err - } - ram, err := messages.MarshalAuthResponse(instanceURL) if err != nil { return nil, fmt.Errorf("failed to marshal auth response msg: %w", err) } return &preparedMsg{ - responseHelloMsg: rhm, - responseAuthMsg: ram, + responseAuthMsg: ram, }, nil } -func marshalResponseHelloMsg(instanceURL string) ([]byte, error) { - addr := &address.Address{URL: instanceURL} - addrData, err := addr.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal response address: %w", err) - } - - //nolint:staticcheck - responseMsg, err := messages.MarshalHelloResponse(addrData) - if err != nil { - return nil, fmt.Errorf("failed to marshal hello response: %w", err) - } - return responseMsg, nil -} - type handshake struct { conn listener.Conn validator Validator preparedMsg *preparedMsg - handshakeMethodAuth bool - peerID *messages.PeerID + peerID *messages.PeerID } func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) { @@ -93,17 +62,11 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err return nil, fmt.Errorf("determine message type from %s: %w", h.conn.RemoteAddr(), err) } - var peerID *messages.PeerID - switch msgType { - //nolint:staticcheck - case messages.MsgTypeHello: - peerID, err = h.handleHelloMsg(buf) - case messages.MsgTypeAuth: - h.handshakeMethodAuth = true - peerID, err = h.handleAuthMsg(buf) - default: + if msgType != messages.MsgTypeAuth { return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr()) } + + peerID, err := h.handleAuthMsg(buf) if err != nil { return peerID, err } @@ -112,46 +75,17 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err } func (h *handshake) handshakeResponse(ctx context.Context) error { - var responseMsg []byte - if h.handshakeMethodAuth { - responseMsg = h.preparedMsg.responseAuthMsg - } else { - responseMsg = h.preparedMsg.responseHelloMsg - } - - if _, err := h.conn.Write(ctx, responseMsg); err != nil { + if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil { return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err) } return nil } -func (h *handshake) handleHelloMsg(buf []byte) (*messages.PeerID, error) { - //nolint:staticcheck - peerID, authData, err := messages.UnmarshalHelloMsg(buf) - if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) - } - - log.Warnf("peer %s (%s) is using deprecated initial message type", peerID, h.conn.RemoteAddr()) - - authMsg, err := authmsg.UnmarshalMsg(authData) - if err != nil { - return nil, fmt.Errorf("unmarshal auth message: %w", err) - } - - //nolint:staticcheck - if err := h.validator.ValidateHelloMsgType(authMsg.AdditionalData); err != nil { - return nil, fmt.Errorf("validate %s (%s): %w", peerID, h.conn.RemoteAddr(), err) - } - - return peerID, nil -} - func (h *handshake) handleAuthMsg(buf []byte) (*messages.PeerID, error) { rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf) if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) + return nil, fmt.Errorf("unmarshal auth message: %w", err) } if err := h.validator.Validate(authPayload); err != nil { diff --git a/relay/server/listener/quic/listener.go b/relay/server/listener/quic/listener.go index 68f0e03c0..4c3b07571 100644 --- a/relay/server/listener/quic/listener.go +++ b/relay/server/listener/quic/listener.go @@ -51,7 +51,10 @@ func (l *Listener) Listen(acceptFn func(conn relaylistener.Conn)) error { log.Infof("QUIC client connected from: %s", session.RemoteAddr()) conn := NewConn(session) - acceptFn(conn) + // Run the accept handler (which performs the pre-auth handshake) in its + // own goroutine so a slow or stalled handshake cannot block accepting + // further connections. + go acceptFn(conn) } } diff --git a/relay/server/listener/ws/listener.go b/relay/server/listener/ws/listener.go index ba175f901..208b9186e 100644 --- a/relay/server/listener/ws/listener.go +++ b/relay/server/listener/ws/listener.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/relay/protocol" relaylistener "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -27,6 +28,9 @@ type Listener struct { Address string // TLSConfig is the TLS configuration for the server. TLSConfig *tls.Config + // TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port + // headers are trusted. Headers from any other immediate peer are ignored. + TrustedProxies *trustedproxy.List server *http.Server acceptFn func(conn relaylistener.Conn) @@ -75,7 +79,7 @@ func (l *Listener) Shutdown(ctx context.Context) error { } func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { - connRemoteAddr := remoteAddr(r) + connRemoteAddr := remoteAddr(r, l.TrustedProxies) acceptOptions := &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, @@ -102,9 +106,17 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { l.acceptFn(conn) } -func remoteAddr(r *http.Request) string { - if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" { +func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string { + realIP := r.Header.Get("X-Real-Ip") + realPort := r.Header.Get("X-Real-Port") + if realIP == "" || realPort == "" { 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) } diff --git a/relay/server/server.go b/relay/server/server.go index 340da55b8..8d303e9e4 100644 --- a/relay/server/server.go +++ b/relay/server/server.go @@ -15,14 +15,17 @@ import ( "github.com/netbirdio/netbird/relay/server/listener/quic" "github.com/netbirdio/netbird/relay/server/listener/ws" quictls "github.com/netbirdio/netbird/shared/relay/tls" + "github.com/netbirdio/netbird/trustedproxy" ) // ListenerConfig is the configuration for the listener. // Address: the address to bind the listener to. It could be an address behind a reverse proxy. // TLSConfig: the TLS configuration for the listener. +// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted. type ListenerConfig struct { - Address string - TLSConfig *tls.Config + Address string + TLSConfig *tls.Config + TrustedProxies *trustedproxy.List } // 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. func (r *Server) Listen(cfg ListenerConfig) error { wSListener := &ws.Listener{ - Address: cfg.Address, - TLSConfig: cfg.TLSConfig, + Address: cfg.Address, + TLSConfig: cfg.TLSConfig, + TrustedProxies: cfg.TrustedProxies, } r.listenerMux.Lock() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 529cd2225..47ca80a7c 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5164,6 +5164,10 @@ components: 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. 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: type: string format: date-time @@ -5184,6 +5188,7 @@ components: - models - enabled - skip_tls_verification + - metadata_disabled - created_at - updated_at AgentNetworkProviderRequest: @@ -5240,6 +5245,10 @@ components: 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. 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: - provider_id - name diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 4956f9a9b..a9e98cf84 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -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 *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 []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 *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 *[]AgentNetworkProviderModel `json:"models,omitempty"` diff --git a/shared/relay/auth/allow/allow_all.go b/shared/relay/auth/allow/allow_all.go index 2d30c59c9..3074b8b1d 100644 --- a/shared/relay/auth/allow/allow_all.go +++ b/shared/relay/auth/allow/allow_all.go @@ -8,7 +8,3 @@ type Auth struct { func (a *Auth) Validate(any) error { return nil } - -func (a *Auth) ValidateHelloMsgType(any) error { - return nil -} diff --git a/shared/relay/auth/hmac/token.go b/shared/relay/auth/hmac/token.go index 581b1d6fd..c908efbff 100644 --- a/shared/relay/auth/hmac/token.go +++ b/shared/relay/auth/hmac/token.go @@ -1,10 +1,8 @@ package hmac import ( - "bytes" "crypto/hmac" "encoding/base64" - "encoding/gob" "fmt" "hash" "strconv" @@ -18,14 +16,6 @@ type Token struct { 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 type TimedHMAC struct { secret string diff --git a/shared/relay/auth/hmac/validator.go b/shared/relay/auth/hmac/validator.go deleted file mode 100644 index b0b7542be..000000000 --- a/shared/relay/auth/hmac/validator.go +++ /dev/null @@ -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) -} diff --git a/shared/relay/auth/validator.go b/shared/relay/auth/validator.go index 8e339bb2e..158b32e1b 100644 --- a/shared/relay/auth/validator.go +++ b/shared/relay/auth/validator.go @@ -1,28 +1,19 @@ package auth import ( - "time" - - auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" authv2 "github.com/netbirdio/netbird/shared/relay/auth/hmac/v2" ) type TimedHMACValidator struct { authenticatorV2 *authv2.Validator - authenticator *auth.TimedHMACValidator } -func NewTimedHMACValidator(secret []byte, duration time.Duration) *TimedHMACValidator { +func NewTimedHMACValidator(secret []byte) *TimedHMACValidator { return &TimedHMACValidator{ authenticatorV2: authv2.NewValidator(secret), - authenticator: auth.NewTimedHMACValidator(string(secret), duration), } } func (a *TimedHMACValidator) Validate(credentials any) error { return a.authenticatorV2.Validate(credentials) } - -func (a *TimedHMACValidator) ValidateHelloMsgType(credentials any) error { - return a.authenticator.Validate(credentials) -} diff --git a/shared/relay/messages/address/address.go b/shared/relay/messages/address/address.go deleted file mode 100644 index 707e73e55..000000000 --- a/shared/relay/messages/address/address.go +++ /dev/null @@ -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 -} diff --git a/shared/relay/messages/auth/auth.go b/shared/relay/messages/auth/auth.go deleted file mode 100644 index 9c2511f2f..000000000 --- a/shared/relay/messages/auth/auth.go +++ /dev/null @@ -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 -} diff --git a/shared/relay/messages/message.go b/shared/relay/messages/message.go index 54671f5df..fdbf2a6e7 100644 --- a/shared/relay/messages/message.go +++ b/shared/relay/messages/message.go @@ -14,9 +14,10 @@ const ( CurrentProtocolVersion = 1 MsgTypeUnknown MsgType = 0 - // Deprecated: Use MsgTypeAuth instead. - MsgTypeHello = 1 - // Deprecated: Use MsgTypeAuthResponse instead. + // MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake + // message types. They are retained only to reserve wire values 1 and 2 so + // the values are never reused; the server rejects both. + MsgTypeHello = 1 MsgTypeHelloResponse = 2 MsgTypeTransport = 3 MsgTypeClose = 4 @@ -42,10 +43,6 @@ const ( offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth - // hello message - headerSizeHello = sizeOfMagicByte + peerIDSize - headerSizeHelloResp = 0 - // transport headerSizeTransport = peerIDSize offsetTransportID = sizeOfProtoHeader @@ -113,7 +110,6 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHello, MsgTypeAuth, MsgTypeTransport, MsgTypeClose, @@ -135,7 +131,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHelloResponse, MsgTypeAuthResponse, MsgTypeTransport, 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 // 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. diff --git a/shared/relay/messages/message_test.go b/shared/relay/messages/message_test.go index 59a89cad1..26a504063 100644 --- a/shared/relay/messages/message_test.go +++ b/shared/relay/messages/message_test.go @@ -4,28 +4,11 @@ import ( "testing" ) -func TestMarshalHelloMsg(t *testing.T) { - peerID := HashID("abdFAaBcawquEiCMzAabYosuUaGLtSNhKxz+") - msg, err := MarshalHelloMsg(peerID, nil) - if err != nil { - t.Fatalf("error: %v", err) - } - - 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) +func TestDetermineClientMessageTypeRejectsHello(t *testing.T) { + // The reserved legacy Hello message (type 1) must be rejected by the server. + msg := []byte{byte(CurrentProtocolVersion), byte(MsgTypeHello)} + if _, err := DetermineClientMessageType(msg); err == nil { + t.Fatalf("expected hello message type to be rejected") } } diff --git a/shared/testing_helpers/populate_fields.go b/shared/testing_helpers/populate_fields.go new file mode 100644 index 000000000..c93d62b69 --- /dev/null +++ b/shared/testing_helpers/populate_fields.go @@ -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 +} diff --git a/trustedproxy/trustedproxy.go b/trustedproxy/trustedproxy.go new file mode 100644 index 000000000..70df01d92 --- /dev/null +++ b/trustedproxy/trustedproxy.go @@ -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{} +} diff --git a/trustedproxy/trustedproxy_test.go b/trustedproxy/trustedproxy_test.go new file mode 100644 index 000000000..2e702a49c --- /dev/null +++ b/trustedproxy/trustedproxy_test.go @@ -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)) + }) + } +}